diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 02d615398..2ea735435 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 69a9ecc43..ec50def2a 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 5a7f5434e..19b114a88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,32 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.8.0
+
+## Enhancements
+- Updates Google Play Billing Library from 8.0.0 to 9.1.0. See the [Play Billing Library 9 migration guide](https://developer.android.com/google/play/billing/migrate-gpblv9) for the full list of changes.
+- Updates the RevenueCat SDK used by the sample apps from 9.2.0 to 10.14.1 for Billing 9 compatibility.
+- Adds support for custom store products. Products configured on a custom store in the Superwall dashboard (e.g. Stripe or your own payment backend) can now be attached to paywalls: their metadata (price, subscription period, trial) is fetched from the Superwall API instead of Google Play and templated into the paywall like any other product. Purchases are routed through your `PurchaseController`, bypassing Google Play Billing entirely — check `product.isCustomProduct` to fulfill them via your own payment flow, and grant their entitlements with `Superwall.instance.setSubscriptionStatus(...)` on success. Requires configuring the SDK with a `PurchaseController`.
+- Adds a unified `PurchaseController.purchase(activity, product: StoreProduct, basePlanId, offerId)` method that handles both Google Play and custom store products. For Play products, the underlying `ProductDetails` are available via `product.rawStoreProduct`.
+- Custom purchases produce full transaction analytics (`transaction_start`/`transaction_complete`, `subscriptionStart`/`freeTrialStart`) with an SDK-generated transaction identifier exposed as `StoreProduct.customTransactionId`, and free-trial eligibility for custom products is derived from the customer's entitlement history.
+- Adds `ApiStoreProduct`, a product backed by Superwall API data, used for custom store products.
+
+## Breaking Changes
+- System back presses are now forwarded into the paywall as a `back_button_input` message instead of dismissing it directly: multi-page flows navigate back one page, and paywalls with nowhere to go back (root page, single page) close themselves through the standard manual-close path (`Declined`/`ManualClose`) — so single-page paywalls dismiss the same as before, from the app's perspective. When `reroute_back_button` is enabled in Paywall settings, the `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. Paywalls built on runtimes that predate `back_button_input` will ignore the press; make sure paywalls are re-published on a current runtime.
+- Removes the deprecated `SuperwallBillingFlowParams.Builder.setSkuDetails(SkuDetails)`. Billing Library 9 removes `SkuDetails` entirely, so this method can no longer exist. Use `setProductDetailsParamsList(...)` with `ProductDetails` instead.
+- Removes the unused internal `com.superwall.sdk.billing.SWProduct`, which wrapped the now-removed `SkuDetails`.
+- Internal purchase-history queries now resolve current purchases via `QueryPurchasesParams` — Billing Library 9 removes the purchase-history APIs (`queryPurchaseHistoryAsync`, `QueryPurchaseHistoryParams`).
+- **Impact:** if your app still calls the removed Billing Library APIs (`SkuDetails`, `SkuDetailsParams`, `querySkuDetailsAsync`, `queryPurchaseHistoryAsync`, `BillingClient.SkuType`, or the no-arg `enablePendingPurchases()`), it will no longer compile once it picks up Billing 9 through this SDK. Migrate those call sites to the `ProductDetails` APIs before upgrading; the [migration guide](https://developer.android.com/google/play/billing/migrate-gpblv9) has a mapping of every removed API to its replacement.
+- **Please test your billing and purchasing flows before shipping this upgrade.** Because the Billing Library is resolved to a single version across your app, upgrading Superwall also upgrades Billing for everything else that depends on it. If you use Google Play Billing directly, or another subscription provider such as RevenueCat, Adapty or Qonversion, make sure that provider's SDK supports Billing 9 and run through purchase, restore and subscription-status flows end to end.
+
+## ⚠️ Minimum SDK version raised to 23
+
+Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSdk` is now **23** (previously 21). If your app's `minSdk` is below 23, you'll need to raise it to pick up this release — devices on Android 5.x will no longer receive app updates that include this SDK version.
+
+## Deprecations
+
+- Deprecates `PurchaseController.purchase(activity, productDetails, basePlanId, offerId)` in favor of the `StoreProduct`-based method above. Existing implementations keep working unchanged — the new method's default implementation routes Google Play purchases to the deprecated one — but purchasing custom store products requires implementing the new method.
+
## 2.7.23
## Fixes
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d69a10500..f180f86d1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -12,7 +12,7 @@ android {
defaultConfig {
applicationId = "com.superwall.superapp"
- minSdk = 22
+ minSdk = 23
targetSdk = 34
versionCode = 2
versionName = "1.0.0"
diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt
index 83a06aadd..6eba3f344 100644
--- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt
+++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt
@@ -2,6 +2,7 @@ package com.superwall.superapp.purchase
import android.app.Activity
import android.content.Context
+import android.util.Log
import com.android.billingclient.api.ProductDetails
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.LogLevel
@@ -27,11 +28,14 @@ import com.superwall.sdk.delegate.RestorationResult
import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.store.Entitlements
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
+import kotlin.String
+import kotlin.collections.mutableListOf
// Extension function to convert callback to suspend function
suspend fun Purchases.awaitProducts(productIds: List): List {
@@ -115,8 +119,10 @@ class RevenueCatPurchaseController(
UpdatedCustomerInfoListener {
private var superwallCustomerInfoJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)
+ val purchased: MutableList
init {
+ purchased = mutableListOf()
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
PurchasesConfiguration
@@ -175,7 +181,7 @@ class RevenueCatPurchaseController(
emptySet()
}
- val allEntitlements = rcEntitlements + webEntitlements
+ val allEntitlements = rcEntitlements + webEntitlements + purchased.map { Entitlement(it) }
if (allEntitlements.isNotEmpty()) {
setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements))
@@ -189,10 +195,27 @@ class RevenueCatPurchaseController(
*/
override suspend fun purchase(
activity: Activity,
- productDetails: ProductDetails,
+ product: com.superwall.sdk.store.abstractions.product.StoreProduct,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
+ // Custom store products aren't on Google Play — fulfill them through your own
+ // payment flow and grant the entitlements yourself.
+ if (product.isCustomProduct) {
+ // Example only, save the entitlements to storage here to persist
+ purchased.add("custom_entitlement")
+
+ // Sync your subscription status
+ Purchases.sharedInstance.getCustomerInfoWith {
+ updateSubscriptionStatus(it)
+ }
+ return PurchaseResult.Purchased()
+ }
+
+ val productDetails =
+ product.rawStoreProduct?.underlyingProductDetails
+ ?: return PurchaseResult.Failed("Missing product details for ${product.fullIdentifier}")
+
// Find products matching productId from RevenueCat
val products = Purchases.sharedInstance.awaitProducts(listOf(productDetails.productId))
// Choose the product which matches the given base plan.
diff --git a/example/app/build.gradle.kts b/example/app/build.gradle.kts
index a10fae23d..2246c2701 100644
--- a/example/app/build.gradle.kts
+++ b/example/app/build.gradle.kts
@@ -10,7 +10,7 @@ android {
defaultConfig {
applicationId = "com.superwall.superapp"
- minSdk = 22
+ minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 268687245..50317a942 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-billing_version = "8.0.0"
+billing_version = "9.1.0"
browser_version = "1.8.0"
gradle_plugin_version = "8.6.1"
jna_version = "5.14.0@aar"
@@ -7,7 +7,7 @@ kotlinxCoroutinesGuavaVersion = "1.9.0"
leakcanaryAndroidVersion = "2.14"
lifecycleProcessVersion = "2.8.5"
orchestratorVersion = "1.5.0"
-revenue_cat_version = "9.2.0"
+revenue_cat_version = "10.14.1"
compose_version = "2024.12.01"
kotlinx_serialization_json_version = "1.7.2"
activity_compose_version = "1.9.3"
diff --git a/superwall-compose/build.gradle.kts b/superwall-compose/build.gradle.kts
index d08bede1c..39150259b 100644
--- a/superwall-compose/build.gradle.kts
+++ b/superwall-compose/build.gradle.kts
@@ -18,7 +18,7 @@ android {
compileSdk = 35
defaultConfig {
- minSdk = 22
+ minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts
index a176a768c..7561d19d4 100644
--- a/superwall/build.gradle.kts
+++ b/superwall/build.gradle.kts
@@ -31,7 +31,7 @@ android {
namespace = "com.superwall.sdk"
defaultConfig {
- minSdkVersion(21)
+ minSdkVersion(23)
targetSdkVersion(33)
aarMetadata {
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt
index 9f0f73bd0..345dc6b20 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt
@@ -57,6 +57,12 @@ class TrackingLogicTest {
override suspend fun makeStoreTransaction(transaction: Purchase): StoreTransaction = mockk()
+ override suspend fun makeStoreTransaction(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: java.util.Date,
+ ): StoreTransaction = mockk()
+
override suspend fun activeProductIds(): List = emptyList()
override suspend fun makeIdentityManager(): IdentityManager = mockk()
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
index cca618f7a..909f00961 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
@@ -294,7 +294,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -400,7 +400,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -525,7 +525,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -581,7 +581,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -631,7 +631,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -676,7 +676,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -722,7 +722,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
any(),
any(),
)
@@ -911,7 +911,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -957,7 +957,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
any(),
any(),
)
@@ -1002,7 +1002,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
any(),
any(),
)
@@ -1050,7 +1050,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -1098,7 +1098,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/BillingClientParamBuilders.kt b/superwall/src/main/java/com/superwall/sdk/billing/BillingClientParamBuilders.kt
index 685084331..20577b10c 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/BillingClientParamBuilders.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/BillingClientParamBuilders.kt
@@ -2,14 +2,13 @@ package com.superwall.sdk.billing
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.QueryProductDetailsParams
-import com.android.billingclient.api.QueryPurchaseHistoryParams
import com.android.billingclient.api.QueryPurchasesParams
-internal fun @receiver:BillingClient.ProductType String.buildQueryPurchaseHistoryParams(): QueryPurchaseHistoryParams? =
+internal fun @receiver:BillingClient.ProductType String.buildQueryPurchaseHistoryParams(): QueryPurchasesParams? =
when (this) {
BillingClient.ProductType.INAPP,
BillingClient.ProductType.SUBS,
- -> QueryPurchaseHistoryParams.newBuilder().setProductType(this).build()
+ -> QueryPurchasesParams.newBuilder().setProductType(this).build()
else -> null
}
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/SWProduct.kt b/superwall/src/main/java/com/superwall/sdk/billing/SWProduct.kt
deleted file mode 100644
index 07512f9d4..000000000
--- a/superwall/src/main/java/com/superwall/sdk/billing/SWProduct.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.superwall.sdk.billing
-
-import com.android.billingclient.api.SkuDetails
-
-data class SWProduct(
- val skuDetails: SkuDetails,
-)
diff --git a/superwall/src/main/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParams.kt b/superwall/src/main/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParams.kt
index 77f8bb5d8..eb91f1007 100644
--- a/superwall/src/main/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParams.kt
+++ b/superwall/src/main/java/com/superwall/sdk/billing/observer/SuperwallBillingFlowParams.kt
@@ -2,7 +2,6 @@ package com.superwall.sdk.billing.observer
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.ProductDetails
-import com.android.billingclient.api.SkuDetails
class SuperwallBillingFlowParams private constructor(
internal val params: BillingFlowParams,
@@ -39,12 +38,6 @@ class SuperwallBillingFlowParams private constructor(
builder.setProductDetailsParamsList(productDetailsParamsList.map { it.toOriginal() })
}
- @Deprecated("Use setProductDetailsParamsList instead")
- fun setSkuDetails(skuDetails: SkuDetails): Builder =
- apply {
- builder.setSkuDetails(skuDetails)
- }
-
fun setSubscriptionUpdateParams(params: SubscriptionUpdateParams): Builder =
apply {
builder.setSubscriptionUpdateParams(params.toOriginal())
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
index 3aa487c56..3ceca48ab 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/PaywallOptions.kt
@@ -160,7 +160,9 @@ class PaywallOptions() {
/**
* This will be invoked when `reroute_back_button` is enabled in Paywall settings.
* You can use this to react to back button presses with custom logic.
- * Return `true` if the dismiss has been consumed, `false` if you prefer to fall back to SDK logic.
+ * Return `true` if the press has been consumed. Return `false` to fall back to SDK
+ * logic: the press is forwarded into the paywall as a `back_button_input` message,
+ * and the paywall either navigates its flow back one page or closes itself.
*/
var onBackPressed: ((PaywallInfo?) -> Boolean)? = null
}
diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt
index 41f769372..2ae61f647 100644
--- a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt
+++ b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt
@@ -5,6 +5,7 @@ import androidx.annotation.MainThread
import com.android.billingclient.api.ProductDetails
import com.superwall.sdk.delegate.PurchaseResult
import com.superwall.sdk.delegate.RestorationResult
+import com.superwall.sdk.store.abstractions.product.StoreProduct
/**
* The interface that handles Superwall's subscription-related logic.
@@ -26,19 +27,74 @@ interface PurchaseController {
* Add your purchase logic here and return its result. You can use Android's Billing APIs,
* or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)).
*
- * @param productDefaults The `ProductDetails` the user would like to purchase.
+ * @param productDetails The `ProductDetails` the user would like to purchase.
* @param basePlanId An optional base plan identifier of the product that's being purchased.
* @param offerId An optional offer identifier of the product that's being purchased.
* @return A `PurchaseResult` object, which is the result of your purchase logic.
* **Note**: Make sure you handle all cases of `PurchaseResult`.
*/
+ @Deprecated(
+ "Implement purchase(activity, product, basePlanId, offerId) instead. " +
+ "It receives a StoreProduct, which also supports custom store products.",
+ ReplaceWith("purchase(activity, product, basePlanId, offerId)"),
+ )
@MainThread
suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
- ): PurchaseResult
+ ): PurchaseResult =
+ PurchaseResult.Failed(
+ "This PurchaseController does not implement a purchase method. " +
+ "Override purchase(activity, product, basePlanId, offerId).",
+ )
+
+ /**
+ * Called when the user initiates purchasing of a product.
+ *
+ * Add your purchase logic here and return its result. You can use Android's Billing APIs,
+ * or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)).
+ *
+ * For Google Play products, the underlying `ProductDetails` are available via
+ * [StoreProduct.rawStoreProduct]'s `underlyingProductDetails`.
+ *
+ * **Custom products**: when [StoreProduct.isCustomProduct] is true, the product's metadata
+ * is sourced from the Superwall API rather than Google Play — route the purchase through
+ * your own payment system (e.g. Stripe, web checkout). The SDK does not grant entitlements
+ * for custom products: on a successful purchase you must update the user's entitlements
+ * yourself by calling `Superwall.instance.setSubscriptionStatus(...)`. The product's
+ * [StoreProduct.customTransactionId] is pre-generated by the SDK and used as the original
+ * transaction identifier in analytics.
+ *
+ * The default implementation routes Google Play products to the deprecated
+ * `purchase(activity, productDetails, basePlanId, offerId)` so existing implementations
+ * keep working, and fails for custom products.
+ *
+ * @param activity The `Activity` from which the purchase was initiated.
+ * @param product The Superwall [StoreProduct] the user would like to purchase.
+ * @param basePlanId An optional base plan identifier of the product that's being purchased.
+ * @param offerId An optional offer identifier of the product that's being purchased.
+ * @return A `PurchaseResult` object, which is the result of your purchase logic.
+ * **Note**: Make sure you handle all cases of `PurchaseResult`.
+ */
+ @MainThread
+ suspend fun purchase(
+ activity: Activity,
+ product: StoreProduct,
+ basePlanId: String?,
+ offerId: String?,
+ ): PurchaseResult {
+ val rawStoreProduct =
+ product.rawStoreProduct
+ ?: return PurchaseResult.Failed(
+ "This PurchaseController does not handle custom products. Override " +
+ "purchase(activity, product, basePlanId, offerId) and handle products " +
+ "where isCustomProduct == true to purchase ${product.fullIdentifier}.",
+ )
+ @Suppress("DEPRECATION")
+ return purchase(activity, rawStoreProduct.underlyingProductDetails, basePlanId, offerId)
+ }
/**
* Called when the user initiates a restore.
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 886f5eca7..86d047bee 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -314,7 +314,7 @@ class DependencyContainer(
InternalPurchaseController(
kotlinPurchaseController =
purchaseController
- ?: AutomaticPurchaseController(context, ioScope, entitlements),
+ ?: AutomaticPurchaseController(context, ioScope, { entitlements }),
javaPurchaseController = null,
context,
)
@@ -330,6 +330,7 @@ class DependencyContainer(
customerInfoManager = { customerInfoManager },
)
},
+ getSuperwallProducts = { network.getSuperwallProducts() },
testMode = testMode,
)
@@ -1073,6 +1074,19 @@ class DependencyContainer(
appSessionId = appSessionManager.appSession.id,
)
+ override suspend fun makeStoreTransaction(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: Date,
+ ): StoreTransaction =
+ StoreTransaction(
+ customTransactionId = customTransactionId,
+ productIdentifier = productIdentifier,
+ purchaseDate = purchaseDate,
+ configRequestId = configManager.config?.requestId ?: "",
+ appSessionId = appSessionManager.appSession.id,
+ )
+
override suspend fun activeProductIds(): List =
storeManager.receiptManager.purchases.toList()
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 27cc6a212..8f597ca53 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt
@@ -49,6 +49,7 @@ import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
import com.superwall.sdk.store.testmode.TestMode
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
+import java.util.Date
interface ApiFactory : JsonFactory {
// TODO: Think of an alternative way such that we don't need to do this:
@@ -238,6 +239,17 @@ interface ConfigManagerFactory {
interface StoreTransactionFactory {
suspend fun makeStoreTransaction(transaction: Purchase): StoreTransaction
+ /**
+ * Builds a StoreTransaction for a custom-product purchase (no Google Play receipt).
+ * [customTransactionId] is the pre-generated UUID used as both original and
+ * store transaction identifier.
+ */
+ suspend fun makeStoreTransaction(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: Date,
+ ): StoreTransaction
+
suspend fun activeProductIds(): List
}
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 4cb102c72..16b9a188c 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
@@ -168,6 +168,9 @@ data class Paywall(
val paddleProducts: List
get() = _productItemsV3.filter { it.storeProduct is CrossplatformProduct.StoreProduct.Paddle }
+ val customProducts: List
+ get() = _productItemsV3.filter { it.storeProduct is CrossplatformProduct.StoreProduct.Custom }
+
// Public getter for productItems
var productItems: List
get() = (
diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt b/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt
index ce5f3d36c..9f525c656 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt
@@ -122,6 +122,18 @@ data class CrossplatformProduct(
)
}
+ @Serializable(with = CustomSerializer::class)
+ @SerialName("CUSTOM")
+ data class Custom(
+ @SerialName("product_identifier")
+ val productIdentifier: String,
+ ) : StoreProduct() {
+ override fun toStoreProductType(): ProductItem.StoreProductType =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = productIdentifier),
+ )
+ }
+
@Serializable(with = OtherSerializer::class)
@SerialName("OTHER")
data class Other(
@@ -150,6 +162,7 @@ data class CrossplatformProduct(
is StoreProduct.AppStore -> storeProduct.productIdentifier
is StoreProduct.Stripe -> storeProduct.productIdentifier
is StoreProduct.Paddle -> storeProduct.productIdentifier
+ is StoreProduct.Custom -> storeProduct.productIdentifier
is StoreProduct.Other -> ""
}
}
@@ -331,6 +344,38 @@ object PaddleSerializer : KSerializer
}
}
+object CustomSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Custom")
+
+ override fun serialize(
+ encoder: Encoder,
+ value: CrossplatformProduct.StoreProduct.Custom,
+ ) {
+ val jsonEncoder =
+ encoder as? JsonEncoder
+ ?: throw SerializationException("This class can be saved only by Json")
+ val jsonObj =
+ buildJsonObject {
+ put("store", JsonPrimitive("CUSTOM"))
+ put("product_identifier", JsonPrimitive(value.productIdentifier))
+ }
+ jsonEncoder.encodeJsonElement(jsonObj)
+ }
+
+ override fun deserialize(decoder: Decoder): CrossplatformProduct.StoreProduct.Custom {
+ val jsonDecoder =
+ decoder as? JsonDecoder
+ ?: throw SerializationException("This class can be loaded only by Json")
+ val jsonObject = jsonDecoder.decodeJsonElement() as JsonObject
+
+ val productId =
+ jsonObject["product_identifier"]?.jsonPrimitive?.content
+ ?: throw SerializationException("product_identifier is missing")
+
+ return CrossplatformProduct.StoreProduct.Custom(productId)
+ }
+}
+
object OtherSerializer : KSerializer {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Other")
@@ -407,7 +452,17 @@ object CrossplatformProductSerializer : KSerializer {
"APP_STORE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
"STRIPE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
"PADDLE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
- "OTHER" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ "CUSTOM" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ "OTHER" -> {
+ // API contract: custom store products are sent with store == OTHER so
+ // SDKs that predate CUSTOM ignore them. Try the custom product shape
+ // first and fall back to Other if the fields don't match.
+ try {
+ decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ } catch (e: SerializationException) {
+ decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ }
+ }
else ->
CrossplatformProduct.StoreProduct.Other(
storeType ?: "OTHER",
diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt
index bec6dd6f1..650c704e0 100644
--- a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt
+++ b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt
@@ -10,6 +10,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.Serializer
import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.listSerialDescriptor
@@ -27,7 +29,7 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
-@Serializable
+@Serializable(with = StoreSerializer::class)
enum class Store {
@SerialName("PLAY_STORE")
PLAY_STORE,
@@ -44,6 +46,9 @@ enum class Store {
@SerialName("SUPERWALL")
SUPERWALL,
+ @SerialName("CUSTOM")
+ CUSTOM,
+
@SerialName("OTHER")
OTHER,
@@ -57,11 +62,25 @@ enum class Store {
"STRIPE" -> STRIPE
"PADDLE" -> PADDLE
"SUPERWALL" -> SUPERWALL
+ "CUSTOM" -> CUSTOM
else -> OTHER
}
}
}
+// Unknown store types from the backend decode as OTHER instead of throwing,
+// regardless of how the decoding Json instance is configured.
+object StoreSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Store", PrimitiveKind.STRING)
+
+ override fun serialize(
+ encoder: Encoder,
+ value: Store,
+ ) = encoder.encodeString(value.name)
+
+ override fun deserialize(decoder: Decoder): Store = Store.fromValue(decoder.decodeString())
+}
+
sealed class Offer {
@Serializable
data class Automatic(
@@ -147,6 +166,17 @@ data class PaddleProduct(
get() = productIdentifier
}
+@Serializable
+data class CustomStoreProduct(
+ @SerialName("store")
+ val store: Store = Store.CUSTOM,
+ @SerialName("product_identifier")
+ val productIdentifier: String,
+) {
+ val fullIdentifier: String
+ get() = productIdentifier
+}
+
@Serializable
data class UnknownStoreProduct(
@SerialName("product_identifier")
@@ -269,6 +299,9 @@ object StoreProductSerializer : KSerializer {
is ProductItem.StoreProductType.Paddle ->
jsonEncoder.json.encodeToJsonElement(PaddleProduct.serializer(), value.product)
+ is ProductItem.StoreProductType.Custom ->
+ jsonEncoder.json.encodeToJsonElement(CustomStoreProduct.serializer(), value.product)
+
is ProductItem.StoreProductType.Other ->
jsonEncoder.json.encodeToJsonElement(
UnknownStoreProduct.serializer(),
@@ -319,13 +352,31 @@ object StoreProductSerializer : KSerializer {
ProductItem.StoreProductType.Paddle(product)
}
- Store.SUPERWALL,
- Store.OTHER,
- -> {
+ Store.CUSTOM -> {
+ val product = json.decodeFromJsonElement(CustomStoreProduct.serializer(), jsonObject)
+ ProductItem.StoreProductType.Custom(product)
+ }
+
+ Store.SUPERWALL -> {
val product =
json.decodeFromJsonElement(UnknownStoreProduct.serializer(), jsonObject)
ProductItem.StoreProductType.Other(product)
}
+
+ Store.OTHER -> {
+ // API contract: custom store products are sent with store == OTHER so SDKs
+ // that predate CUSTOM ignore them. Try the custom product shape first and
+ // fall back to Other if the fields don't match.
+ try {
+ val product =
+ json.decodeFromJsonElement(CustomStoreProduct.serializer(), jsonObject)
+ ProductItem.StoreProductType.Custom(product)
+ } catch (e: SerializationException) {
+ val product =
+ json.decodeFromJsonElement(UnknownStoreProduct.serializer(), jsonObject)
+ ProductItem.StoreProductType.Other(product)
+ }
+ }
}
}
}
@@ -366,6 +417,11 @@ data class ProductItem(
val product: PaddleProduct,
) : StoreProductType()
+ @Serializable
+ data class Custom(
+ val product: CustomStoreProduct,
+ ) : StoreProductType()
+
@Serializable
data class Other(
val product: UnknownStoreProduct,
@@ -379,6 +435,7 @@ data class ProductItem(
is StoreProductType.AppStore -> type.product.fullIdentifier
is StoreProductType.Stripe -> type.product.fullIdentifier
is StoreProductType.Paddle -> type.product.fullIdentifier
+ is StoreProductType.Custom -> type.product.fullIdentifier
is StoreProductType.Other -> type.product.productIdentifier
}
@@ -447,6 +504,7 @@ object ProductItemSerializer : KSerializer {
is ProductItem.StoreProductType.AppStore -> storeProductType.product.fullIdentifier
is ProductItem.StoreProductType.Stripe -> storeProductType.product.fullIdentifier
is ProductItem.StoreProductType.Paddle -> storeProductType.product.fullIdentifier
+ is ProductItem.StoreProductType.Custom -> storeProductType.product.fullIdentifier
is ProductItem.StoreProductType.Other -> storeProductType.product.productIdentifier
}
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 bde46b80e..12932335e 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
@@ -161,9 +161,47 @@ object PaywallLogic {
customerInfo = customerInfo,
introOfferEligibility = introOfferEligibility,
)
+
+ is ProductItem.StoreProductType.Custom -> {
+ // Custom product trial info lives on the cached StoreProduct's
+ // subscription metadata (fetched from /products). Use the same
+ // entitlement-history check as web products.
+ val trialDays = productsByFullId[productItem.fullProductId]?.trialPeriodDays ?: 0
+ isWebTrialAvailable(
+ name = productItem.name,
+ trialDays = trialDays,
+ entitlements = productItem.entitlements,
+ customerInfo = customerInfo,
+ introOfferEligibility = introOfferEligibility,
+ )
+ }
}
}
+ /**
+ * Entitlement-history-based trial eligibility for a custom (store == CUSTOM) product,
+ * mirroring the check used for web products in [computeHasFreeTrial]. Used at purchase
+ * time so a repeat purchaser who already consumed their trial does not generate a
+ * spurious `freeTrialStart` event — the external payment system charges them immediately.
+ *
+ * Returns false when the product has no trial metadata, when eligibility can't be
+ * verified (e.g. no entitlements or customer info not yet loaded), or when the customer
+ * has ever held one of the product's entitlements.
+ */
+ fun isFreeTrialEligibleForCustomProduct(
+ product: StoreProduct,
+ entitlements: Set,
+ customerInfo: CustomerInfo,
+ introOfferEligibility: IntroOfferEligibility = IntroOfferEligibility.AUTOMATIC,
+ ): Boolean =
+ isWebTrialAvailable(
+ name = product.fullIdentifier,
+ trialDays = product.trialPeriodDays,
+ entitlements = entitlements,
+ customerInfo = customerInfo,
+ introOfferEligibility = introOfferEligibility,
+ )
+
private fun isWebTrialAvailable(
name: String,
trialDays: Int?,
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 8199fe731..0f099e806 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
@@ -18,6 +18,7 @@ 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.models.product.ProductItem
import com.superwall.sdk.network.Network
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.paywall.presentation.internal.request.ProductOverride
@@ -297,17 +298,47 @@ class PaywallRequestManager(
var paywall = paywall
paywall = trackProductsLoadStart(paywall, request)
- paywall =
- try {
- getProducts(paywall, request)
- } catch (error: Throwable) {
- throw error
- }
+ try {
+ // Custom products (store == CUSTOM) come from /products, not Play Billing.
+ // A /products failure is fatal — mirror BillingNotAvailable below.
+ fetchAndCacheCustomProducts(paywall)
+ } catch (error: Throwable) {
+ paywall.productsLoadingInfo.failAt = Date()
+ track(
+ InternalSuperwallEvent.PaywallProductsLoad(
+ state = InternalSuperwallEvent.PaywallProductsLoad.State.Fail(error.message),
+ paywallInfo = paywall.getInfo(request.eventData),
+ eventData = request.eventData,
+ ),
+ )
+ throw error
+ }
+ paywall = getProducts(paywall, request)
paywall = trackProductsLoadFinish(paywall, request.eventData)
return@withContext paywall
}
+ /**
+ * Fetches custom products (ProductItem.StoreProductType.Custom) from the Superwall
+ * /products endpoint and caches them in StoreManager so the downstream getProducts
+ * flow finds them already loaded.
+ *
+ * Idempotent: skips entirely when no custom products need refreshing. A /products
+ * failure is propagated (required = true) so [addProducts] surfaces it as a tracked
+ * product-load failure instead of presenting a paywall with missing prices.
+ */
+ private suspend fun fetchAndCacheCustomProducts(paywall: Paywall) {
+ val customIds =
+ paywall.productItems
+ .filter { it.type is ProductItem.StoreProductType.Custom }
+ .map { it.fullProductId }
+ .toSet()
+ if (customIds.isEmpty()) return
+
+ storeManager.fetchAndCacheCustomProducts(customIds, required = true)
+ }
+
private suspend fun getProducts(
paywall: Paywall,
request: PaywallRequest,
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt
new file mode 100644
index 000000000..e2124bb2e
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt
@@ -0,0 +1,18 @@
+package com.superwall.sdk.paywall.view
+
+import com.superwall.sdk.models.paywall.Paywall
+
+/**
+ * Decides whether the host app's `PaywallOptions.onBackPressed` callback
+ * consumes a system back press. The callback is only consulted when
+ * `reroute_back_button` is enabled in Paywall settings and a callback is set.
+ *
+ * A press the app doesn't consume is forwarded into the paywall as a
+ * `back_button_input` message: the paywall navigates its flow back one page
+ * when possible, and otherwise posts `close` to dismiss through the
+ * standard manual-close path.
+ */
+internal fun isBackPressConsumedByApp(
+ rerouteBackButton: Paywall.ToggleMode?,
+ consumedByApp: () -> Boolean,
+): Boolean = rerouteBackButton == Paywall.ToggleMode.ENABLED && consumedByApp()
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 c0cad2da9..13b2b4130 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
@@ -1105,6 +1105,19 @@ class PaywallView(
//endregion
+//region Back button
+
+ /**
+ * Forwards a system back press into the paywall (`back_button_input`).
+ * The paywall either navigates its flow back one page or posts `close`,
+ * which dismisses via the standard manual-close path.
+ */
+ fun backButtonPressed() {
+ webView.messageHandler.handle(PaywallMessage.BackButtonPressed)
+ }
+
+//endregion
+
//region Misc
// Android-specific
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 8e858ae07..6c832ce4f 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
@@ -52,7 +52,6 @@ import com.superwall.sdk.misc.isLightColor
import com.superwall.sdk.misc.onError
import com.superwall.sdk.misc.readableOverlayColor
import com.superwall.sdk.models.paywall.LocalNotification
-import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.paywall.PaywallPresentationStyle
import com.superwall.sdk.network.JsonFactory
import com.superwall.sdk.paywall.presentation.PaywallCloseReason
@@ -297,20 +296,26 @@ class SuperwallPaywallActivity : AppCompatActivity() {
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
- val shouldConsumeDismiss =
- if (paywallView()?.state?.paywall?.rerouteBackButton == Paywall.ToggleMode.ENABLED) {
- Superwall.instance.options.paywalls.onBackPressed
- ?.let { it(paywallView()?.info) }
- ?: false
- } else {
- false
- }
- if (!shouldConsumeDismiss) {
- view.dismiss(
+ val paywallView = paywallView()
+ val consumedByApp =
+ isBackPressConsumedByApp(
+ rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton,
+ consumedByApp = {
+ Superwall.instance.options.paywalls.onBackPressed
+ ?.let { it(paywallView?.info) }
+ ?: false
+ },
+ )
+ if (consumedByApp) {
+ return
+ }
+ // The paywall handles the press (navigate back or close); native
+ // dismissal only remains for the edge where no view is attached.
+ paywallView?.backButtonPressed()
+ ?: view.dismiss(
result = PaywallResult.Declined(),
closeReason = PaywallCloseReason.ManualClose,
)
- }
}
},
)
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt
index 8e75bcf41..cab61190a 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt
@@ -83,6 +83,13 @@ sealed class PaywallMessage {
object PaywallClose : PaywallMessage()
+ /**
+ * SDK-originated: forwards a system back press into the paywall as
+ * `back_button_input`. The paywall navigates its flow back one page or
+ * posts `close` to dismiss.
+ */
+ object BackButtonPressed : PaywallMessage()
+
object TransactionStart : PaywallMessage()
object TransactionAbandon : PaywallMessage()
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
index a520bfb30..3f496e9ea 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt
@@ -202,6 +202,11 @@ class PaywallMessageHandler(
}
}
+ is PaywallMessage.BackButtonPressed ->
+ ioScope.launch {
+ pass(eventName = "back_button_input", paywall = paywall)
+ }
+
is PaywallMessage.Custom -> handleCustomEvent(message.data)
is PaywallMessage.CustomPlacement -> handleCustomPlacement(message.name, message.params)
is PaywallMessage.RestoreFailed ->
diff --git a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
index a3afcb12b..1c2f523c8 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
@@ -29,6 +29,7 @@ import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.store.abstractions.product.BasePlanType
import com.superwall.sdk.store.abstractions.product.OfferType
import com.superwall.sdk.store.abstractions.product.RawStoreProduct
+import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.transactions.PlayBillingErrors
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -50,7 +51,7 @@ private val BILLING_INSANTIATION_ERROR =
class AutomaticPurchaseController(
var context: Context,
val scope: IOScope,
- val entitlementsInfo: Entitlements,
+ val entitlementsInfo: () -> Entitlements = { Superwall.instance.dependencyContainer.entitlements },
val getBilling: (Context, PurchasesUpdatedListener) -> BillingClient = { ctx, listener ->
try {
BillingClient
@@ -110,7 +111,7 @@ class AutomaticPurchaseController(
LogLevel.error,
LogScope.nativePurchaseController,
"ExternalNativePurchaseController billing client disconnected, " +
- "retrying in $reconnectMilliseconds milliseconds",
+ "retrying in $reconnectMilliseconds milliseconds",
)
CoroutineScope(Dispatchers.IO).launch {
@@ -161,6 +162,13 @@ class AutomaticPurchaseController(
offerId?.let { append(":$it") }
}
+ // The billing flow lives on the deprecated ProductDetails signature; the
+ // PurchaseController default bridges purchase(activity, product, ...) to it.
+ @Deprecated(
+ "Implement purchase(activity, product, basePlanId, offerId) instead. " +
+ "It receives a StoreProduct, which also supports custom store products.",
+ ReplaceWith("purchase(activity, product, basePlanId, offerId)"),
+ )
override suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
@@ -349,7 +357,7 @@ class AutomaticPurchaseController(
it.products
}.toSet()
.flatMap {
- val res = entitlementsInfo.byProductId(it)
+ val res = entitlementsInfo().byProductId(it)
res
}.toSet()
.let { entitlements ->
@@ -359,7 +367,7 @@ class AutomaticPurchaseController(
message = "Found entitlements: ${entitlements.joinToString { it.id }}",
)
- entitlementsInfo.activeDeviceEntitlements = entitlements
+ entitlementsInfo().activeDeviceEntitlements = entitlements
if (entitlements.isNotEmpty()) {
SubscriptionStatus.Active(
entitlements.map { it.copy(isActive = true) }.toSet(),
diff --git a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt
index f74f12532..5a79661a7 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt
@@ -7,6 +7,7 @@ import com.superwall.sdk.delegate.PurchaseResult
import com.superwall.sdk.delegate.RestorationResult
import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.delegate.subscription_controller.PurchaseControllerJava
+import com.superwall.sdk.store.abstractions.product.StoreProduct
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -21,44 +22,59 @@ class InternalPurchaseController(
val hasInternalPurchaseController: Boolean
get() = kotlinPurchaseController is AutomaticPurchaseController
+ @Deprecated(
+ "Implement purchase(activity, product, basePlanId, offerId) instead. " +
+ "It receives a StoreProduct, which also supports custom store products.",
+ ReplaceWith("purchase(activity, product, basePlanId, offerId)"),
+ )
override suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
- // TODO: Await beginPurchase with purchasing coordinator: https://linear.app/superwall/issue/SW-2415/[android]-implement-purchasingcoordinator
-
if (kotlinPurchaseController != null) {
+ @Suppress("DEPRECATION")
return kotlinPurchaseController.purchase(activity, productDetails, basePlanId, offerId)
- } else if (javaPurchaseController != null) {
+ }
+ return purchaseWithJavaController(productDetails, basePlanId, offerId)
+ }
+
+ override suspend fun purchase(
+ activity: Activity,
+ product: StoreProduct,
+ basePlanId: String?,
+ offerId: String?,
+ ): PurchaseResult {
+ if (kotlinPurchaseController != null) {
+ return kotlinPurchaseController.purchase(activity, product, basePlanId, offerId)
+ }
+
+ // PurchaseControllerJava predates StoreProduct — bridge Play products to its
+ // ProductDetails signature; custom products can't be represented there.
+ val productDetails = product.rawStoreProduct?.underlyingProductDetails
+ if (productDetails != null) {
+ return purchaseWithJavaController(productDetails, basePlanId, offerId)
+ }
+ return PurchaseResult.Failed(
+ "No PurchaseController configured to handle custom product purchase.",
+ )
+ }
+
+ private suspend fun purchaseWithJavaController(
+ productDetails: ProductDetails,
+ basePlanId: String?,
+ offerId: String?,
+ ): PurchaseResult {
+ if (javaPurchaseController != null) {
return suspendCoroutine { continuation ->
javaPurchaseController.purchase(productDetails, basePlanId, offerId) { result ->
continuation.resume(result)
}
}
- } else {
- // Here is where we would implement our own product purchaser.
- return PurchaseResult.Cancelled()
}
-
-//
-// kotlinPurchaseController?.let {
-// return it.purchase(currentActivity as Activity, sk1Product.skuDetails)
-// }
-//
-// // There used to be a failure if raw store product wasn't present but there isn't anymore...
-// // not sure why
-// }
-//
-// // SW-2217
-// // https://linear.app/superwall/issue/SW-2217/%5Bandroid%5D-%5Bv1%5D-add-back-support-for-javanon-kotlinxcoroutines-purchase
-// // javaPurchaseController?.let {
-// // product.sk1Product?.let { sk1Product ->
-// // return it.purchase(sk1Product)
-// // } ?: return PurchaseResult.failed(PurchaseError.productUnavailable)
-// // }
-// return PurchaseResult.Cancelled()
+ // Here is where we would implement our own product purchaser.
+ return PurchaseResult.Cancelled()
}
override suspend fun restorePurchases(): RestorationResult {
diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
index 744171660..698e6bc4f 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
@@ -9,16 +9,21 @@ import com.superwall.sdk.billing.DecomposedProductIds
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.fold
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.product.PlayStoreProduct
import com.superwall.sdk.models.product.ProductItem
import com.superwall.sdk.models.product.ProductVariable
+import com.superwall.sdk.network.NetworkError
import com.superwall.sdk.paywall.request.PaywallRequest
+import com.superwall.sdk.store.abstractions.product.ApiStoreProduct
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager
import com.superwall.sdk.store.coordinator.ProductsFetcher
import com.superwall.sdk.store.testmode.TestMode
+import com.superwall.sdk.store.testmode.models.SuperwallProductsResponse
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitAll
import java.util.Date
@@ -31,6 +36,9 @@ class StoreManager(
private val track: suspend (InternalSuperwallEvent) -> Unit = {
Superwall.instance.track(it)
},
+ private val getSuperwallProducts: suspend () -> Either = {
+ Either.Success(SuperwallProductsResponse(emptyList()))
+ },
var testMode: TestMode? = null,
) : ProductsFetcher,
StoreKit {
@@ -71,8 +79,10 @@ class StoreManager(
request = request,
)
+ // Iterate all product items (not just Play Store) so custom products surface variables
+ // too; only DebugView consumes this, but a custom product should still show there.
val productAttributes =
- paywall.playStoreProducts.mapNotNull { productItem ->
+ paywall.productItems.mapNotNull { productItem ->
output.productsByFullId[productItem.fullProductId]?.let { storeProduct ->
ProductVariable(
name = productItem.name,
@@ -101,15 +111,88 @@ class StoreManager(
)
val productsById = processingResult.substituteProductsById.toMutableMap()
- val fetchResult = fetchOrAwaitProducts(processingResult.fullProductIdsToLoad)
- for ((id, product) in fetchResult) {
- productsById[id] = product
+ // Try Play Billing first so Play-only lookups never pay for a /products round-trip.
+ val billingError =
+ try {
+ for ((id, product) in fetchOrAwaitProducts(processingResult.fullProductIdsToLoad)) {
+ productsById[id] = product
+ }
+ null
+ } catch (e: Throwable) {
+ e
+ }
+
+ // Anything Play couldn't resolve may be a custom (store == CUSTOM) product, which
+ // doesn't need Play Billing — probe /products only for those.
+ val unresolved = processingResult.fullProductIdsToLoad - productsById.keys
+ if (unresolved.isNotEmpty()) {
+ fetchAndCacheCustomProducts(unresolved, required = false)
+ for (id in unresolved) {
+ getProductFromCache(id)?.let { productsById[id] = it }
+ }
+ }
+
+ // Surface the billing error only if /products didn't cover the gap.
+ if (billingError != null && (processingResult.fullProductIdsToLoad - productsById.keys).isNotEmpty()) {
+ throw billingError
}
return productsById
}
+ /**
+ * Fetches custom (store == CUSTOM) products from the Superwall /products endpoint and
+ * caches them so downstream lookups resolve them from cache instead of Google Play
+ * Billing. Ids that are already cached, empty, or absent from /products are left
+ * untouched (the latter fall through to billing).
+ *
+ * @param candidateIds The product identifiers that may be custom products.
+ * @param required When true — a paywall declared these ids as custom products — a
+ * /products failure is rethrown so the caller can surface a product-load failure.
+ * When false — a best-effort probe where the ids may well be Play products — the
+ * failure is logged and swallowed so the ids fall through to billing.
+ */
+ suspend fun fetchAndCacheCustomProducts(
+ candidateIds: Set,
+ required: Boolean,
+ ) {
+ val idsNeedingRefresh =
+ candidateIds
+ .filterNot { it.isEmpty() }
+ .filterNot { hasCached(it) }
+ .toSet()
+ if (idsNeedingRefresh.isEmpty()) return
+
+ getSuperwallProducts().fold(
+ onSuccess = { response ->
+ val seenIds = mutableSetOf()
+ response.data
+ .filter { it.identifier in idsNeedingRefresh }
+ .forEach { superwallProduct ->
+ if (!seenIds.add(superwallProduct.identifier)) {
+ Logger.debug(
+ LogLevel.warn,
+ LogScope.productsManager,
+ "Duplicate custom product id from /products: ${superwallProduct.identifier}",
+ )
+ return@forEach
+ }
+ val apiProduct = ApiStoreProduct(superwallProduct)
+ cacheProduct(superwallProduct.identifier, StoreProduct.custom(apiProduct))
+ }
+ },
+ onFailure = { error ->
+ Logger.debug(
+ LogLevel.error,
+ LogScope.productsManager,
+ "Failed to fetch custom products: ${error.message}",
+ )
+ if (required) throw error
+ },
+ )
+ }
+
override suspend fun getProducts(
substituteProducts: Map?,
paywall: Paywall,
diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt
new file mode 100644
index 000000000..f7fcbf266
--- /dev/null
+++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt
@@ -0,0 +1,293 @@
+package com.superwall.sdk.store.abstractions.product
+
+import com.superwall.sdk.store.testmode.models.SuperwallProduct
+import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
+import com.superwall.sdk.store.testmode.models.SuperwallSubscriptionPeriod
+import com.superwall.sdk.utilities.DateUtils
+import com.superwall.sdk.utilities.localizedDateFormat
+import java.math.BigDecimal
+import java.math.RoundingMode
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+/**
+ * StoreProductType backed by a Superwall API product (from the /products endpoint).
+ * Shared between test mode products and custom (CUSTOM-store) products that are not
+ * fetched from Google Play.
+ */
+open class ApiStoreProduct(
+ private val superwallProduct: SuperwallProduct,
+) : StoreProductType {
+ /** The platform this product belongs to, as reported by the /products endpoint. */
+ val platform: SuperwallProductPlatform
+ get() = superwallProduct.platform
+
+ private val priceFormatterProvider = PriceFormatterProvider()
+
+ private val priceFormatter by lazy {
+ currencyCode?.let { priceFormatterProvider.priceFormatter(it) }
+ }
+
+ override val fullIdentifier: String
+ get() = superwallProduct.identifier
+
+ override val productIdentifier: String
+ get() = superwallProduct.identifier
+
+ override val storeFrontCountryCode: String?
+ get() = superwallProduct.storefront
+
+ override val price: BigDecimal by lazy {
+ val amount = superwallProduct.price?.amount ?: 0
+ BigDecimal(amount).divide(BigDecimal(100), 2, RoundingMode.DOWN)
+ }
+
+ override val localizedPrice: String by lazy {
+ priceFormatter?.format(price) ?: "${currencySymbol ?: "$"}$price"
+ }
+
+ override val subscriptionPeriod: SubscriptionPeriod? by lazy {
+ superwallProduct.subscription?.let { sub ->
+ val unit =
+ when (sub.period) {
+ SuperwallSubscriptionPeriod.DAY -> SubscriptionPeriod.Unit.day
+ SuperwallSubscriptionPeriod.WEEK -> SubscriptionPeriod.Unit.week
+ SuperwallSubscriptionPeriod.MONTH -> SubscriptionPeriod.Unit.month
+ SuperwallSubscriptionPeriod.YEAR -> SubscriptionPeriod.Unit.year
+ }
+ SubscriptionPeriod(value = sub.periodCount, unit = unit)
+ }
+ }
+
+ override val localizedSubscriptionPeriod: String by lazy {
+ subscriptionPeriod?.let {
+ val count = it.value
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> if (count == 1) "1 day" else "$count days"
+ SubscriptionPeriod.Unit.week -> if (count == 1) "1 week" else "$count weeks"
+ SubscriptionPeriod.Unit.month -> if (count == 1) "1 month" else "$count months"
+ SubscriptionPeriod.Unit.year -> if (count == 1) "1 year" else "$count years"
+ }
+ } ?: ""
+ }
+
+ override val period: String by lazy {
+ subscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> if (it.value == 7) "week" else "day"
+ SubscriptionPeriod.Unit.week -> "week"
+ SubscriptionPeriod.Unit.month ->
+ when (it.value) {
+ 2 -> "2 months"
+ 3 -> "quarter"
+ 6 -> "6 months"
+ else -> "month"
+ }
+ SubscriptionPeriod.Unit.year -> "year"
+ }
+ } ?: ""
+ }
+
+ override val periodly: String by lazy {
+ subscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.month ->
+ when (it.value) {
+ 2, 6 -> "every $period"
+ else -> "${period}ly"
+ }
+ else -> "${period}ly"
+ }
+ } ?: ""
+ }
+
+ override val periodWeeks: Int by lazy {
+ subscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> (it.value) / 7
+ SubscriptionPeriod.Unit.week -> it.value
+ SubscriptionPeriod.Unit.month -> 4 * it.value
+ SubscriptionPeriod.Unit.year -> 52 * it.value
+ }
+ } ?: 0
+ }
+
+ override val periodWeeksString: String by lazy { periodWeeks.toString() }
+
+ override val periodMonths: Int by lazy {
+ subscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> it.value / 30
+ SubscriptionPeriod.Unit.week -> it.value / 4
+ SubscriptionPeriod.Unit.month -> it.value
+ SubscriptionPeriod.Unit.year -> 12 * it.value
+ }
+ } ?: 0
+ }
+
+ override val periodMonthsString: String by lazy { periodMonths.toString() }
+
+ override val periodYears: Int by lazy {
+ subscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> it.value / 365
+ SubscriptionPeriod.Unit.week -> it.value / 52
+ SubscriptionPeriod.Unit.month -> it.value / 12
+ SubscriptionPeriod.Unit.year -> it.value
+ }
+ } ?: 0
+ }
+
+ override val periodYearsString: String by lazy { periodYears.toString() }
+
+ override val periodDays: Int by lazy {
+ subscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> it.value
+ SubscriptionPeriod.Unit.week -> 7 * it.value
+ SubscriptionPeriod.Unit.month -> 30 * it.value
+ SubscriptionPeriod.Unit.year -> 365 * it.value
+ }
+ } ?: 0
+ }
+
+ override val periodDaysString: String by lazy { periodDays.toString() }
+
+ override val dailyPrice: String by lazy {
+ val sp = subscriptionPeriod ?: return@lazy "n/a"
+ if (price.compareTo(BigDecimal.ZERO) == 0) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
+ priceFormatter?.format(sp.pricePerDay(price)) ?: "n/a"
+ }
+
+ override val weeklyPrice: String by lazy {
+ val sp = subscriptionPeriod ?: return@lazy "n/a"
+ if (price.compareTo(BigDecimal.ZERO) == 0) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
+ priceFormatter?.format(sp.pricePerWeek(price)) ?: "n/a"
+ }
+
+ override val monthlyPrice: String by lazy {
+ val sp = subscriptionPeriod ?: return@lazy "n/a"
+ if (price.compareTo(BigDecimal.ZERO) == 0) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
+ priceFormatter?.format(sp.pricePerMonth(price)) ?: "n/a"
+ }
+
+ override val yearlyPrice: String by lazy {
+ val sp = subscriptionPeriod ?: return@lazy "n/a"
+ if (price == BigDecimal.ZERO) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
+ priceFormatter?.format(sp.pricePerYear(price)) ?: "n/a"
+ }
+
+ private val trialDays: Int
+ get() = superwallProduct.subscription?.trialPeriodDays ?: 0
+
+ override val hasFreeTrial: Boolean by lazy {
+ trialDays > 0
+ }
+
+ override val localizedTrialPeriodPrice: String by lazy {
+ priceFormatter?.format(trialPeriodPrice) ?: "$0.00"
+ }
+
+ override val trialPeriodPrice: BigDecimal = BigDecimal.ZERO
+
+ private val trialSubscriptionPeriod: SubscriptionPeriod? by lazy {
+ if (trialDays <= 0) return@lazy null
+ SubscriptionPeriod(value = trialDays, unit = SubscriptionPeriod.Unit.day).normalized()
+ }
+
+ override val trialPeriodEndDate: Date? by lazy {
+ if (trialDays <= 0) return@lazy null
+ val calendar = Calendar.getInstance()
+ calendar.add(Calendar.DAY_OF_YEAR, trialDays)
+ calendar.time
+ }
+
+ override val trialPeriodEndDateString: String by lazy {
+ trialPeriodEndDate?.let {
+ localizedDateFormat(DateUtils.MMM_dd_yyyy).format(it)
+ } ?: ""
+ }
+
+ override val trialPeriodDays: Int by lazy {
+ trialSubscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> it.value
+ SubscriptionPeriod.Unit.week -> 7 * it.value
+ SubscriptionPeriod.Unit.month -> 30 * it.value
+ SubscriptionPeriod.Unit.year -> 365 * it.value
+ }
+ } ?: 0
+ }
+
+ override val trialPeriodDaysString: String by lazy { trialPeriodDays.toString() }
+
+ override val trialPeriodWeeks: Int by lazy {
+ trialSubscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> it.value / 7
+ SubscriptionPeriod.Unit.week -> it.value
+ SubscriptionPeriod.Unit.month -> 4 * it.value
+ SubscriptionPeriod.Unit.year -> 52 * it.value
+ }
+ } ?: 0
+ }
+
+ override val trialPeriodWeeksString: String by lazy { trialPeriodWeeks.toString() }
+
+ override val trialPeriodMonths: Int by lazy {
+ trialSubscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> it.value / 30
+ SubscriptionPeriod.Unit.week -> it.value / 4
+ SubscriptionPeriod.Unit.month -> it.value
+ SubscriptionPeriod.Unit.year -> 12 * it.value
+ }
+ } ?: 0
+ }
+
+ override val trialPeriodMonthsString: String by lazy { trialPeriodMonths.toString() }
+
+ override val trialPeriodYears: Int by lazy {
+ trialSubscriptionPeriod?.let {
+ when (it.unit) {
+ SubscriptionPeriod.Unit.day -> it.value / 365
+ SubscriptionPeriod.Unit.week -> it.value / 52
+ SubscriptionPeriod.Unit.month -> it.value / 12
+ SubscriptionPeriod.Unit.year -> it.value
+ }
+ } ?: 0
+ }
+
+ override val trialPeriodYearsString: String by lazy { trialPeriodYears.toString() }
+
+ override val trialPeriodText: String by lazy {
+ if (trialDays <= 0) return@lazy ""
+ "$trialDays-day"
+ }
+
+ override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String = priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
+
+ override val locale: String by lazy { Locale.getDefault().toString() }
+
+ override val languageCode: String? by lazy { Locale.getDefault().language }
+
+ override val currencyCode: String? by lazy { superwallProduct.price?.currency }
+
+ override val currencySymbol: String? by lazy {
+ currencyCode?.let {
+ try {
+ java.util.Currency
+ .getInstance(it)
+ .symbol
+ } catch (e: Throwable) {
+ null
+ }
+ }
+ }
+
+ override val regionCode: String? by lazy { Locale.getDefault().country }
+
+ override val productType: String
+ get() = if (superwallProduct.subscription != null) "subs" else "inapp"
+}
diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt
index 0cbb1c0e5..af51d222b 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt
@@ -1,6 +1,7 @@
package com.superwall.sdk.store.abstractions.product
import com.superwall.sdk.models.product.Offer
+import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
import kotlinx.serialization.Serializable
import java.util.*
@@ -81,5 +82,30 @@ class StoreProduct private constructor(
) : StoreProductType by backingProduct {
constructor(rawStoreProduct: RawStoreProduct) : this(rawStoreProduct, rawStoreProduct)
+ /**
+ * Whether this is a custom (store == CUSTOM) product — an API-backed product whose
+ * platform is `custom`, purchased through the developer's PurchaseController rather
+ * than Google Play Billing.
+ */
+ val isCustomProduct: Boolean
+ get() = (backingProduct as? ApiStoreProduct)?.platform == SuperwallProductPlatform.CUSTOM
+
constructor(storeProductType: StoreProductType) : this(null, storeProductType)
+
+ /**
+ * Pre-generated transaction id for the current custom-product (store == CUSTOM) purchase,
+ * exposed so the developer can read it inside `purchase(activity, product, ...)`. Regenerated on
+ * each attempt. Because custom instances are cached and shared, this field is only reliable
+ * within a single non-overlapping flow; analytics use a local copy so they stay correct.
+ */
+ var customTransactionId: String? = null
+
+ companion object {
+ /** Builds a StoreProduct backed by an ApiStoreProduct (custom or test-mode product). */
+ fun custom(apiStoreProduct: ApiStoreProduct): StoreProduct =
+ StoreProduct(
+ rawStoreProduct = null,
+ backingProduct = apiStoreProduct,
+ )
+ }
}
diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt
index e8cf0fb75..138911898 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt
@@ -17,6 +17,43 @@ class StoreTransaction(
@SerialName("app_session_id")
val appSessionId: String,
) : StoreTransactionType {
+ /**
+ * Builds a StoreTransaction representing a custom (non-Play-Billing) purchase
+ * completed by an external PurchaseController. The pre-generated
+ * [customTransactionId] is used as both [originalTransactionIdentifier] and
+ * [storeTransactionId], mirroring the iOS CustomStoreTransaction.
+ */
+ constructor(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: Date,
+ configRequestId: String,
+ appSessionId: String,
+ ) : this(
+ transaction =
+ GoogleBillingPurchaseTransaction(
+ underlyingSK2Transaction = null,
+ transactionDate = purchaseDate,
+ originalTransactionIdentifier = customTransactionId,
+ state = StoreTransactionState.Purchased,
+ storeTransactionId = customTransactionId,
+ originalTransactionDate = purchaseDate,
+ webOrderLineItemID = null,
+ appBundleId = null,
+ subscriptionGroupId = null,
+ isUpgraded = null,
+ expirationDate = null,
+ offerId = null,
+ revocationDate = null,
+ appAccountToken = null,
+ purchaseToken = "",
+ payment = StorePayment(productIdentifier = productIdentifier, quantity = 1, discountIdentifier = null),
+ signature = null,
+ ),
+ configRequestId = configRequestId,
+ appSessionId = appSessionId,
+ )
+
val id = UUID.randomUUID().toString()
override val transactionDate: Date? get() = transaction.transactionDate
diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt
index a69e34aad..11c61b5d7 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt
@@ -1,286 +1,14 @@
package com.superwall.sdk.store.testmode
-import com.superwall.sdk.store.abstractions.product.PriceFormatterProvider
-import com.superwall.sdk.store.abstractions.product.StoreProductType
-import com.superwall.sdk.store.abstractions.product.SubscriptionPeriod
+import com.superwall.sdk.store.abstractions.product.ApiStoreProduct
import com.superwall.sdk.store.testmode.models.SuperwallProduct
-import com.superwall.sdk.store.testmode.models.SuperwallSubscriptionPeriod
-import com.superwall.sdk.utilities.DateUtils
-import com.superwall.sdk.utilities.localizedDateFormat
-import java.math.BigDecimal
-import java.math.RoundingMode
-import java.util.Calendar
-import java.util.Date
-import java.util.Locale
+/**
+ * StoreProductType backing test-mode products. Shares its implementation with
+ * [ApiStoreProduct] — both are built from Superwall /products endpoint data — but
+ * remains a distinct type so test-mode products are distinguishable from custom
+ * (store == CUSTOM) products.
+ */
class TestStoreProduct(
- private val superwallProduct: SuperwallProduct,
-) : StoreProductType {
- private val priceFormatterProvider = PriceFormatterProvider()
-
- private val priceFormatter by lazy {
- currencyCode?.let { priceFormatterProvider.priceFormatter(it) }
- }
-
- override val fullIdentifier: String
- get() = superwallProduct.identifier
-
- override val productIdentifier: String
- get() = superwallProduct.identifier
-
- override val storeFrontCountryCode: String?
- get() = superwallProduct.storefront
-
- override val price: BigDecimal by lazy {
- val amount = superwallProduct.price?.amount ?: 0
- BigDecimal(amount).divide(BigDecimal(100), 2, RoundingMode.DOWN)
- }
-
- override val localizedPrice: String by lazy {
- priceFormatter?.format(price) ?: "${currencySymbol ?: "$"}$price"
- }
-
- override val subscriptionPeriod: SubscriptionPeriod? by lazy {
- superwallProduct.subscription?.let { sub ->
- val unit =
- when (sub.period) {
- SuperwallSubscriptionPeriod.DAY -> SubscriptionPeriod.Unit.day
- SuperwallSubscriptionPeriod.WEEK -> SubscriptionPeriod.Unit.week
- SuperwallSubscriptionPeriod.MONTH -> SubscriptionPeriod.Unit.month
- SuperwallSubscriptionPeriod.YEAR -> SubscriptionPeriod.Unit.year
- }
- SubscriptionPeriod(value = sub.periodCount, unit = unit)
- }
- }
-
- override val localizedSubscriptionPeriod: String by lazy {
- subscriptionPeriod?.let {
- val count = it.value
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> if (count == 1) "1 day" else "$count days"
- SubscriptionPeriod.Unit.week -> if (count == 1) "1 week" else "$count weeks"
- SubscriptionPeriod.Unit.month -> if (count == 1) "1 month" else "$count months"
- SubscriptionPeriod.Unit.year -> if (count == 1) "1 year" else "$count years"
- }
- } ?: ""
- }
-
- override val period: String by lazy {
- subscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> if (it.value == 7) "week" else "day"
- SubscriptionPeriod.Unit.week -> "week"
- SubscriptionPeriod.Unit.month ->
- when (it.value) {
- 2 -> "2 months"
- 3 -> "quarter"
- 6 -> "6 months"
- else -> "month"
- }
- SubscriptionPeriod.Unit.year -> "year"
- }
- } ?: ""
- }
-
- override val periodly: String by lazy {
- subscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.month ->
- when (it.value) {
- 2, 6 -> "every $period"
- else -> "${period}ly"
- }
- else -> "${period}ly"
- }
- } ?: ""
- }
-
- override val periodWeeks: Int by lazy {
- subscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> (it.value) / 7
- SubscriptionPeriod.Unit.week -> it.value
- SubscriptionPeriod.Unit.month -> 4 * it.value
- SubscriptionPeriod.Unit.year -> 52 * it.value
- }
- } ?: 0
- }
-
- override val periodWeeksString: String by lazy { periodWeeks.toString() }
-
- override val periodMonths: Int by lazy {
- subscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> it.value / 30
- SubscriptionPeriod.Unit.week -> it.value / 4
- SubscriptionPeriod.Unit.month -> it.value
- SubscriptionPeriod.Unit.year -> 12 * it.value
- }
- } ?: 0
- }
-
- override val periodMonthsString: String by lazy { periodMonths.toString() }
-
- override val periodYears: Int by lazy {
- subscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> it.value / 365
- SubscriptionPeriod.Unit.week -> it.value / 52
- SubscriptionPeriod.Unit.month -> it.value / 12
- SubscriptionPeriod.Unit.year -> it.value
- }
- } ?: 0
- }
-
- override val periodYearsString: String by lazy { periodYears.toString() }
-
- override val periodDays: Int by lazy {
- subscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> it.value
- SubscriptionPeriod.Unit.week -> 7 * it.value
- SubscriptionPeriod.Unit.month -> 30 * it.value
- SubscriptionPeriod.Unit.year -> 365 * it.value
- }
- } ?: 0
- }
-
- override val periodDaysString: String by lazy { periodDays.toString() }
-
- override val dailyPrice: String by lazy {
- val sp = subscriptionPeriod ?: return@lazy "n/a"
- if (price.compareTo(BigDecimal.ZERO) == 0) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
- priceFormatter?.format(sp.pricePerDay(price)) ?: "n/a"
- }
-
- override val weeklyPrice: String by lazy {
- val sp = subscriptionPeriod ?: return@lazy "n/a"
- if (price.compareTo(BigDecimal.ZERO) == 0) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
- priceFormatter?.format(sp.pricePerWeek(price)) ?: "n/a"
- }
-
- override val monthlyPrice: String by lazy {
- val sp = subscriptionPeriod ?: return@lazy "n/a"
- if (price.compareTo(BigDecimal.ZERO) == 0) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
- priceFormatter?.format(sp.pricePerMonth(price)) ?: "n/a"
- }
-
- override val yearlyPrice: String by lazy {
- val sp = subscriptionPeriod ?: return@lazy "n/a"
- if (price == BigDecimal.ZERO) return@lazy priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
- priceFormatter?.format(sp.pricePerYear(price)) ?: "n/a"
- }
-
- private val trialDays: Int
- get() = superwallProduct.subscription?.trialPeriodDays ?: 0
-
- override val hasFreeTrial: Boolean by lazy {
- trialDays > 0
- }
-
- override val localizedTrialPeriodPrice: String by lazy {
- priceFormatter?.format(trialPeriodPrice) ?: "$0.00"
- }
-
- override val trialPeriodPrice: BigDecimal = BigDecimal.ZERO
-
- private val trialSubscriptionPeriod: SubscriptionPeriod? by lazy {
- if (trialDays <= 0) return@lazy null
- SubscriptionPeriod(value = trialDays, unit = SubscriptionPeriod.Unit.day).normalized()
- }
-
- override val trialPeriodEndDate: Date? by lazy {
- if (trialDays <= 0) return@lazy null
- val calendar = Calendar.getInstance()
- calendar.add(Calendar.DAY_OF_YEAR, trialDays)
- calendar.time
- }
-
- override val trialPeriodEndDateString: String by lazy {
- trialPeriodEndDate?.let {
- localizedDateFormat(DateUtils.MMM_dd_yyyy).format(it)
- } ?: ""
- }
-
- override val trialPeriodDays: Int by lazy {
- trialSubscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> it.value
- SubscriptionPeriod.Unit.week -> 7 * it.value
- SubscriptionPeriod.Unit.month -> 30 * it.value
- SubscriptionPeriod.Unit.year -> 365 * it.value
- }
- } ?: 0
- }
-
- override val trialPeriodDaysString: String by lazy { trialPeriodDays.toString() }
-
- override val trialPeriodWeeks: Int by lazy {
- trialSubscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> it.value / 7
- SubscriptionPeriod.Unit.week -> it.value
- SubscriptionPeriod.Unit.month -> 4 * it.value
- SubscriptionPeriod.Unit.year -> 52 * it.value
- }
- } ?: 0
- }
-
- override val trialPeriodWeeksString: String by lazy { trialPeriodWeeks.toString() }
-
- override val trialPeriodMonths: Int by lazy {
- trialSubscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> it.value / 30
- SubscriptionPeriod.Unit.week -> it.value / 4
- SubscriptionPeriod.Unit.month -> it.value
- SubscriptionPeriod.Unit.year -> 12 * it.value
- }
- } ?: 0
- }
-
- override val trialPeriodMonthsString: String by lazy { trialPeriodMonths.toString() }
-
- override val trialPeriodYears: Int by lazy {
- trialSubscriptionPeriod?.let {
- when (it.unit) {
- SubscriptionPeriod.Unit.day -> it.value / 365
- SubscriptionPeriod.Unit.week -> it.value / 52
- SubscriptionPeriod.Unit.month -> it.value / 12
- SubscriptionPeriod.Unit.year -> it.value
- }
- } ?: 0
- }
-
- override val trialPeriodYearsString: String by lazy { trialPeriodYears.toString() }
-
- override val trialPeriodText: String by lazy {
- if (trialDays <= 0) return@lazy ""
- "$trialDays-day"
- }
-
- override fun trialPeriodPricePerUnit(unit: SubscriptionPeriod.Unit): String = priceFormatter?.format(BigDecimal.ZERO) ?: "$0.00"
-
- override val locale: String by lazy { Locale.getDefault().toString() }
-
- override val languageCode: String? by lazy { Locale.getDefault().language }
-
- override val currencyCode: String? by lazy { superwallProduct.price?.currency }
-
- override val currencySymbol: String? by lazy {
- currencyCode?.let {
- try {
- java.util.Currency
- .getInstance(it)
- .symbol
- } catch (e: Throwable) {
- null
- }
- }
- }
-
- override val regionCode: String? by lazy { Locale.getDefault().country }
-
- override val productType: String
- get() = if (superwallProduct.subscription != null) "subs" else "inapp"
-}
+ superwallProduct: SuperwallProduct,
+) : ApiStoreProduct(superwallProduct)
diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt
index 19acc928a..39628896a 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt
@@ -72,4 +72,7 @@ enum class SuperwallProductPlatform {
@SerialName("superwall")
SUPERWALL,
+
+ @SerialName("custom")
+ CUSTOM,
}
diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
index 30f19d13e..5d95f9e20 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
@@ -32,9 +32,11 @@ import com.superwall.sdk.misc.ActivityProvider
import com.superwall.sdk.misc.AlertControllerFactory.AlertProps
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.misc.launchWithTracking
+import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.paywall.presentation.PaywallInfo
+import com.superwall.sdk.paywall.request.PaywallLogic
import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult
import com.superwall.sdk.paywall.view.PaywallView
import com.superwall.sdk.paywall.view.PaywallViewState
@@ -56,6 +58,8 @@ import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
+import java.util.Date
+import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class TransactionManager(
@@ -79,6 +83,9 @@ class TransactionManager(
private val webEntitlements: () -> Set = {
Superwall.instance.entitlements.web
},
+ private val currentCustomerInfo: () -> CustomerInfo = {
+ Superwall.instance.getCustomerInfo()
+ },
private val showRestoreDialogForWeb: suspend () -> Unit,
private val notifyBackendOfReceipts: suspend () -> Unit = {},
private val refreshReceipt: () -> Unit,
@@ -354,6 +361,12 @@ class TransactionManager(
return result
}
+ // Custom product flow: store == CUSTOM products are handled by the external
+ // PurchaseController, not Google Play Billing.
+ if (product.isCustomProduct) {
+ return handleCustomProductPurchase(product, purchaseSource, shouldDismiss)
+ }
+
val rawStoreProduct =
product.rawStoreProduct
?: return PurchaseResult.Failed("Missing raw store product for ${product.fullIdentifier}")
@@ -361,7 +374,6 @@ class TransactionManager(
message =
"!!! Purchasing product ${rawStoreProduct.hasFreeTrial}",
)
- val productDetails = rawStoreProduct.underlyingProductDetails
val activity =
activityProvider.getCurrentActivity()
?: return PurchaseResult.Failed("Activity not found - required for starting the billing flow")
@@ -370,7 +382,7 @@ class TransactionManager(
val result =
storeManager.purchaseController.purchase(
activity = activity,
- productDetails = productDetails,
+ product = product,
offerId = rawStoreProduct.offerId,
basePlanId = rawStoreProduct.basePlanId,
)
@@ -435,6 +447,163 @@ class TransactionManager(
return result
}
+ /**
+ * Handles purchase of a custom (store == CUSTOM) product. Requires an external
+ * PurchaseController; fails fast with a clear error otherwise. Pre-generates a
+ * UUID transaction identifier on each attempt, routes the purchase through
+ * [PurchaseController.purchase(activity, product, basePlanId, offerId)], and
+ * constructs a StoreTransaction without touching Google Play Billing / receipts.
+ */
+ private suspend fun handleCustomProductPurchase(
+ product: StoreProduct,
+ purchaseSource: PurchaseSource,
+ shouldDismiss: Boolean,
+ ): PurchaseResult {
+ if (!factory.makeHasExternalPurchaseController()) {
+ val message =
+ "Custom product \"${product.fullIdentifier}\" can only be purchased using a " +
+ "PurchaseController. Set one via Superwall.configure(..., purchaseController:) " +
+ "and handle products where isCustomProduct == true in " +
+ "purchase(activity, product, basePlanId, offerId)."
+ log(message = message, error = Error(message))
+ trackFailure(message, product, purchaseSource)
+ if (purchaseSource is PurchaseSource.Internal) {
+ // Surface the misconfiguration on the paywall, mirroring other purchase
+ // failures, instead of silently hiding the spinner.
+ val options = factory.makeSuperwallOptions()
+ val transactionFailExists =
+ factory.makeTriggers().contains(SuperwallEvents.TransactionFail.rawName)
+ if (options.paywalls.shouldShowPurchaseFailureAlert && !transactionFailExists) {
+ presentAlert(Error(message), product, purchaseSource.state)
+ } else {
+ updateState(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallViewState.Updates.ToggleSpinner(hidden = true),
+ )
+ }
+ }
+ return PurchaseResult.Failed(message)
+ }
+
+ // Local copy keeps this flow consistent even if a concurrent purchase overwrites the
+ // shared product's field; product.customTransactionId is set for the dev-facing contract.
+ val txnId = UUID.randomUUID().toString()
+ product.customTransactionId = txnId
+
+ // Entitlement-history-based eligibility, not raw trial metadata: a repeat purchaser who
+ // already used their trial is charged immediately and must not emit freeTrialStart.
+ val didStartFreeTrial =
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = product,
+ entitlements = entitlementsById(product.fullIdentifier),
+ customerInfo = currentCustomerInfo(),
+ )
+
+ prepareToPurchase(product, purchaseSource)
+
+ // prepareToPurchase skips the Start event for an external-only controller, but a custom
+ // purchase is otherwise invisible to Superwall — emit Start so it pairs with the Complete
+ // tracked on success and transaction funnels stay balanced.
+ val isExternalOnly =
+ purchaseSource is PurchaseSource.ExternalPurchase &&
+ factory.makeHasExternalPurchaseController() &&
+ !factory.makeHasInternalPurchaseController()
+ if (isExternalOnly) {
+ track(
+ InternalSuperwallEvent.Transaction(
+ InternalSuperwallEvent.Transaction.State.Start(product),
+ PaywallInfo.empty(),
+ product,
+ null,
+ isObserved = false,
+ source = TransactionSource.EXTERNAL,
+ demandScore = factory.demandScore(),
+ demandTier = factory.demandTier(),
+ ),
+ )
+ }
+
+ val activity =
+ activityProvider.getCurrentActivity()
+ ?: return PurchaseResult.Failed("Activity not found - required for starting the purchase flow")
+ val result =
+ storeManager.purchaseController.purchase(
+ activity = activity,
+ product = product,
+ basePlanId = null,
+ offerId = null,
+ )
+
+ // For an external-only controller the dev's flow owns the rest of the lifecycle; still
+ // build + track the transaction so analytics include the custom txn id.
+ if (isExternalOnly) {
+ if (result is PurchaseResult.Purchased) {
+ val transaction =
+ factory.makeStoreTransaction(
+ customTransactionId = txnId,
+ productIdentifier = product.fullIdentifier,
+ purchaseDate = Date(),
+ )
+ trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial)
+ }
+ return result
+ }
+
+ when (result) {
+ is PurchaseResult.Purchased -> {
+ val transaction =
+ factory.makeStoreTransaction(
+ customTransactionId = txnId,
+ productIdentifier = product.fullIdentifier,
+ purchaseDate = Date(),
+ )
+ // Skip storeManager.loadPurchasedProducts — no Play receipt for custom products.
+ trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial)
+ if (shouldDismiss &&
+ purchaseSource is PurchaseSource.Internal &&
+ factory.makeSuperwallOptions().paywalls.automaticallyDismiss
+ ) {
+ dismiss(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallResult.Purchased(product.fullIdentifier),
+ )
+ } else if (purchaseSource is PurchaseSource.Internal) {
+ updateState(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallViewState.Updates.SetLoadingState(PaywallLoadingState.Ready),
+ )
+ }
+ }
+
+ is PurchaseResult.Failed -> {
+ trackFailure(result.errorMessage, product, purchaseSource)
+ if (purchaseSource is PurchaseSource.Internal) {
+ val options = factory.makeSuperwallOptions()
+ val triggers = factory.makeTriggers()
+ val transactionFailExists =
+ triggers.contains(SuperwallEvents.TransactionFail.rawName)
+ if (options.paywalls.shouldShowPurchaseFailureAlert && !transactionFailExists) {
+ presentAlert(Error(result.errorMessage), product, purchaseSource.state)
+ } else {
+ updateState(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallViewState.Updates.ToggleSpinner(hidden = true),
+ )
+ }
+ }
+ }
+
+ is PurchaseResult.Cancelled -> {
+ trackCancelled(product, purchaseSource)
+ }
+
+ is PurchaseResult.Pending -> {
+ handlePendingTransaction(purchaseSource)
+ }
+ }
+ return result
+ }
+
private suspend fun didRestore(
product: StoreProduct? = null,
purchaseSource: PurchaseSource,
diff --git a/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt b/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
index 37649d0ba..9fb4a61c4 100644
--- a/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
@@ -14,7 +14,7 @@ import com.superwall.sdk.misc.AppLifecycleObserver
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.store.StoreManager
-import com.superwall.sdk.store.testmode.TestStoreProduct
+import com.superwall.sdk.store.abstractions.product.ApiStoreProduct
import com.superwall.sdk.store.testmode.models.SuperwallProduct
import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
import io.mockk.Runs
@@ -135,7 +135,7 @@ class StorefrontTest {
}
private val someProduct =
- TestStoreProduct(
+ ApiStoreProduct(
SuperwallProduct(
identifier = "test_product",
platform = SuperwallProductPlatform.ANDROID,
@@ -193,7 +193,7 @@ class StorefrontTest {
fun testStoreProduct_exposesBackendProvidedStorefront() {
Given("a test mode product with a backend-provided storefront") {
val product =
- TestStoreProduct(
+ ApiStoreProduct(
SuperwallProduct(
identifier = "test_product",
platform = SuperwallProductPlatform.ANDROID,
@@ -212,7 +212,7 @@ class StorefrontTest {
fun storeProduct_withoutStorefront_fallsBackToNa() {
Given("a product without any storefront information") {
val product =
- TestStoreProduct(
+ ApiStoreProduct(
SuperwallProduct(
identifier = "test_product",
platform = SuperwallProductPlatform.ANDROID,
diff --git a/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt
new file mode 100644
index 000000000..ed6f36f1a
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt
@@ -0,0 +1,173 @@
+@file:Suppress("ktlint:standard:function-naming")
+
+package com.superwall.sdk.models.product
+
+import com.superwall.sdk.Given
+import com.superwall.sdk.Then
+import com.superwall.sdk.When
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CustomStoreProductTest {
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ encodeDefaults = true
+ }
+
+ @Test
+ fun `deserializes a CUSTOM store product item via the polymorphic serializer`() {
+ Given("a JSON store_product payload with store CUSTOM") {
+ val payload =
+ """
+ {
+ "store": "CUSTOM",
+ "product_identifier": "stripe_pro_monthly"
+ }
+ """.trimIndent()
+
+ When("decoded via StoreProductSerializer") {
+ val decoded = json.decodeFromString(StoreProductSerializer, payload)
+
+ Then("the result is a Custom variant with the right identifier") {
+ assertTrue(decoded is ProductItem.StoreProductType.Custom)
+ val custom = (decoded as ProductItem.StoreProductType.Custom).product
+ assertEquals("stripe_pro_monthly", custom.productIdentifier)
+ assertEquals(Store.CUSTOM, custom.store)
+ assertEquals("stripe_pro_monthly", custom.fullIdentifier)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `round-trips CustomStoreProduct through the serializer`() {
+ Given("a Custom store product type") {
+ val original =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = "stripe_pro_yearly"),
+ )
+
+ When("encoded then decoded") {
+ val encoded = json.encodeToString(StoreProductSerializer, original)
+ val decoded = json.decodeFromString(StoreProductSerializer, encoded)
+
+ Then("the result equals the original") {
+ assertTrue(decoded is ProductItem.StoreProductType.Custom)
+ assertEquals(
+ original.product.productIdentifier,
+ (decoded as ProductItem.StoreProductType.Custom).product.productIdentifier,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `deserializes an OTHER store product with custom fields as Custom`() {
+ Given("a store_product payload with store OTHER carrying a product_identifier") {
+ // API contract: the backend resolves custom store products to OTHER so SDKs
+ // that predate CUSTOM ignore them.
+ val payload =
+ """
+ {
+ "store": "OTHER",
+ "product_identifier": "custom_year_10_trial_week"
+ }
+ """.trimIndent()
+
+ When("decoded via StoreProductSerializer") {
+ val decoded = json.decodeFromString(StoreProductSerializer, payload)
+
+ Then("the result is a Custom variant preserving the wire store value") {
+ assertTrue(decoded is ProductItem.StoreProductType.Custom)
+ val custom = (decoded as ProductItem.StoreProductType.Custom).product
+ assertEquals("custom_year_10_trial_week", custom.productIdentifier)
+ assertEquals(Store.OTHER, custom.store)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `deserializes a products_v2 OTHER entry as a Custom product item`() {
+ Given("a full products_v2 entry as served by the paywall endpoint") {
+ val payload =
+ """
+ {
+ "sw_composite_product_id": "custom_year_10_trial_week",
+ "reference_name": "primary",
+ "store_product": {
+ "store": "OTHER",
+ "product_identifier": "custom_year_10_trial_week"
+ },
+ "entitlements": [
+ { "identifier": "default", "type": "SERVICE_LEVEL" }
+ ]
+ }
+ """.trimIndent()
+
+ When("decoded as a ProductItem") {
+ val decoded = json.decodeFromString(ProductItemSerializer, payload)
+
+ Then("it is a Custom product with the composite id") {
+ assertTrue(decoded.type is ProductItem.StoreProductType.Custom)
+ assertEquals("custom_year_10_trial_week", decoded.compositeId)
+ assertEquals("custom_year_10_trial_week", decoded.fullProductId)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `deserializes a crossplatform OTHER store product with custom fields as Custom`() {
+ Given("a crossplatform product payload with store OTHER carrying a product_identifier") {
+ val payload =
+ """
+ {
+ "sw_composite_product_id": "custom_year_10_trial_week",
+ "store_product": {
+ "store": "OTHER",
+ "product_identifier": "custom_year_10_trial_week"
+ },
+ "entitlements": []
+ }
+ """.trimIndent()
+
+ When("decoded as a CrossplatformProduct") {
+ val decoded = json.decodeFromString(CrossplatformProduct.serializer(), payload)
+
+ Then("the store product is the Custom variant") {
+ assertTrue(decoded.storeProduct is CrossplatformProduct.StoreProduct.Custom)
+ assertEquals(
+ "custom_year_10_trial_week",
+ (decoded.storeProduct as CrossplatformProduct.StoreProduct.Custom).productIdentifier,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `ProductItem fullProductId returns the identifier for Custom`() {
+ Given("a ProductItem wrapping a Custom store product") {
+ val item =
+ ProductItem(
+ compositeId = "stripe_pro_monthly",
+ name = "pro",
+ type =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = "stripe_pro_monthly"),
+ ),
+ entitlements = emptySet(),
+ )
+
+ Then("fullProductId is the underlying identifier") {
+ assertEquals("stripe_pro_monthly", item.fullProductId)
+ }
+ }
+ }
+}
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 531bff726..7af458f39 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
@@ -4,6 +4,7 @@ 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.CustomStoreProduct
import com.superwall.sdk.models.product.PaddleProduct
import com.superwall.sdk.models.product.ProductItem
import com.superwall.sdk.models.product.Store
@@ -598,6 +599,97 @@ class PaywallLogicTest {
assertFalse(outcome.isFreeTrialAvailable)
}
+ // ---- Custom (store == CUSTOM) product trial eligibility ----
+
+ private fun customItem(
+ entitlements: Set = setOf(proEntitlement),
+ productId: String = "custom_pro_1",
+ ): ProductItem {
+ val customType =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = productId),
+ )
+ return mockk {
+ every { name } returns "primary"
+ every { fullProductId } returns productId
+ every { type } returns customType
+ every { this@mockk.entitlements } returns entitlements
+ }
+ }
+
+ private fun storeProductWithTrialDays(trialDays: Int): StoreProduct =
+ mockk {
+ every { attributes } returns emptyMap()
+ every { trialPeriodDays } returns trialDays
+ }
+
+ @Test
+ fun test_custom_trialAvailable_whenCustomerHasNoEntitlementHistory() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(),
+ )
+
+ assertTrue(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_trialBlocked_whenEntitlementAlreadyHeld() {
+ val consumed = proEntitlement.copy(latestProductId = "custom_pro_old", store = Store.CUSTOM)
+
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(consumed),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_noTrial_whenTrialDaysZero() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(0)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_noTrial_whenEntitlementsEmpty() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem(entitlements = emptySet())),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_trialBlocked_whenCustomerInfoIsPlaceholder() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = CustomerInfo.empty(),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
@Test
fun test_override_winsOverIneligible() {
val outcome =
@@ -611,4 +703,83 @@ class PaywallLogicTest {
assertTrue(outcome.isFreeTrialAvailable)
}
+
+ // ---- isFreeTrialEligibleForCustomProduct (purchase-time eligibility) ----
+
+ private fun customStoreProduct(
+ trialDays: Int,
+ id: String = "custom_pro_1",
+ ): StoreProduct =
+ mockk {
+ every { fullIdentifier } returns id
+ every { trialPeriodDays } returns trialDays
+ }
+
+ @Test
+ fun test_customEligibility_true_whenTrialAndNoHistory() {
+ assertTrue(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenTrialAlreadyConsumed() {
+ val consumed = proEntitlement.copy(latestProductId = "custom_pro_old", store = Store.CUSTOM)
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(consumed),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenNoTrialDays() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 0),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenNoEntitlements() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = emptySet(),
+ customerInfo = customerInfoWithEntitlements(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenCustomerInfoIsPlaceholder() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = CustomerInfo.empty(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenIntroOfferIneligible() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(),
+ introOfferEligibility = IntroOfferEligibility.INELIGIBLE,
+ ),
+ )
+ }
}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt
new file mode 100644
index 000000000..5f06e5284
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt
@@ -0,0 +1,70 @@
+package com.superwall.sdk.paywall.view
+
+import com.superwall.sdk.Given
+import com.superwall.sdk.Then
+import com.superwall.sdk.When
+import com.superwall.sdk.models.paywall.Paywall
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BackPressBehaviorTest {
+ @Test
+ fun app_callback_is_not_consulted_when_reroute_is_disabled() {
+ Given("a paywall with reroute_back_button disabled and a consuming app callback") {
+ var callbackInvoked = false
+
+ When("the back button is pressed") {
+ val consumed =
+ isBackPressConsumedByApp(Paywall.ToggleMode.DISABLED) {
+ callbackInvoked = true
+ true
+ }
+
+ Then("the press is not consumed and the callback is never invoked") {
+ assertFalse(consumed)
+ assertFalse(callbackInvoked)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun app_callback_is_not_consulted_when_reroute_is_unset() {
+ Given("a paywall without a reroute_back_button setting") {
+ When("the back button is pressed") {
+ val consumed = isBackPressConsumedByApp(null) { true }
+
+ Then("the press is not consumed") {
+ assertFalse(consumed)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun app_callback_consumes_the_press_when_reroute_is_enabled() {
+ Given("a paywall with reroute_back_button enabled and a consuming app callback") {
+ When("the back button is pressed") {
+ val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { true }
+
+ Then("the press is consumed by the app") {
+ assertTrue(consumed)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun press_is_not_consumed_when_reroute_is_enabled_and_app_declines() {
+ Given("a paywall with reroute_back_button enabled and a non-consuming app callback") {
+ When("the back button is pressed") {
+ val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { false }
+
+ Then("the press is not consumed and falls through to the paywall") {
+ assertFalse(consumed)
+ }
+ }
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
index c9a7665ea..281f42933 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallMessageHandlerTest.kt
@@ -266,6 +266,37 @@ class PaywallMessageHandlerTest {
}
}
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun handler_backButtonPressed_passesBackButtonInputToWebView() =
+ runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ Dispatchers.setMain(dispatcher)
+ val scope = CoroutineScope(dispatcher + Job())
+
+ try {
+ Given("a handler harness") {
+ val harness = buildHandlerHarness(scope)
+
+ When("BackButtonPressed is handled") {
+ harness.handler.handle(PaywallMessage.BackButtonPressed)
+ advanceUntilIdle()
+
+ Then("a back_button_input message is passed into the webview") {
+ assertTrue(
+ "Expected back_button_input in evaluate calls: ${harness.webUI.evaluateCalls}",
+ harness.webUI.evaluateCalls.any {
+ it.contains("window.paywall.accept64") && it.contains("back_button_input")
+ },
+ )
+ }
+ }
+ }
+ } finally {
+ Dispatchers.resetMain()
+ }
+ }
+
private data class HandlerHarness(
val paywallView: PaywallView,
val handler: PaywallMessageHandler,
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
index 6a95ac06d..d6b265535 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt
@@ -237,6 +237,21 @@ class PaywallViewTest {
}
}
+ @Test
+ fun backButtonPressed_forwardsToMessageHandler() {
+ Given("a paywall presented with the system back button pressed") {
+ every { messageHandler.handle(any()) } just Runs
+
+ When("the press is forwarded to the paywall") {
+ paywallView.backButtonPressed()
+
+ Then("the message handler receives BackButtonPressed") {
+ verify(exactly = 1) { messageHandler.handle(PaywallMessage.BackButtonPressed) }
+ }
+ }
+ }
+ }
+
@Test
fun destroyWebview_forwardsCall() {
Given("a PaywallView") {
diff --git a/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt
index f172ec1c0..9c9388bc1 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt
@@ -104,6 +104,61 @@ class InternalPurchaseControllerTest {
}
}
+ @Test
+ fun `test unified purchase delegates to Kotlin controller`() =
+ runTest {
+ Given("an InternalPurchaseController with an external Kotlin controller") {
+ val externalController = mockk()
+ val product = mockk()
+ val expectedResult = PurchaseResult.Purchased()
+
+ coEvery {
+ externalController.purchase(mockActivity, product, "base_plan", "offer")
+ } returns expectedResult
+
+ val controller =
+ InternalPurchaseController(
+ kotlinPurchaseController = externalController,
+ javaPurchaseController = null,
+ context = mockContext,
+ )
+
+ When("calling purchase with a StoreProduct") {
+ val result = controller.purchase(mockActivity, product, "base_plan", "offer")
+
+ Then("it should delegate to the external controller") {
+ assertEquals(expectedResult, result)
+ coVerify(exactly = 1) {
+ externalController.purchase(mockActivity, product, "base_plan", "offer")
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `test unified purchase of a custom product fails without a Kotlin controller`() =
+ runTest {
+ Given("an InternalPurchaseController with no Kotlin controller") {
+ val product = mockk()
+ every { product.rawStoreProduct } returns null
+ val controller =
+ InternalPurchaseController(
+ kotlinPurchaseController = null,
+ javaPurchaseController = null,
+ context = mockContext,
+ )
+
+ When("calling purchase with a custom product") {
+ val result = controller.purchase(mockActivity, product, null, null)
+
+ Then("it should return Failed") {
+ assertTrue(result is PurchaseResult.Failed)
+ }
+ }
+ }
+ }
+
@Test
fun `test purchase delegates to Kotlin controller`() =
runTest {
diff --git a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt
index a411b611f..5e8403f16 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt
@@ -7,14 +7,21 @@ import com.superwall.sdk.When
import com.superwall.sdk.assertTrue
import com.superwall.sdk.billing.Billing
import com.superwall.sdk.billing.BillingError
+import com.superwall.sdk.misc.Either
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.product.CrossplatformProduct
import com.superwall.sdk.models.product.Offer
+import com.superwall.sdk.network.NetworkError
import com.superwall.sdk.paywall.request.PaywallRequest
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.testmode.TestMode
import com.superwall.sdk.store.testmode.TestModeBehavior
+import com.superwall.sdk.store.testmode.models.SuperwallProduct
+import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
+import com.superwall.sdk.store.testmode.models.SuperwallProductSubscription
+import com.superwall.sdk.store.testmode.models.SuperwallProductsResponse
+import com.superwall.sdk.store.testmode.models.SuperwallSubscriptionPeriod
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -658,6 +665,60 @@ class StoreManagerTest {
}
}
+ // ---- Custom (store == CUSTOM) products ----
+
+ private fun customProductsResponse(vararg ids: String): SuperwallProductsResponse =
+ SuperwallProductsResponse(
+ data =
+ ids.map {
+ SuperwallProduct(
+ identifier = it,
+ platform = SuperwallProductPlatform.CUSTOM,
+ subscription =
+ SuperwallProductSubscription(
+ period = SuperwallSubscriptionPeriod.MONTH,
+ trialPeriodDays = 7,
+ ),
+ )
+ },
+ )
+
+ private fun storeManagerWith(getSuperwallProducts: suspend () -> Either): StoreManager =
+ StoreManager(
+ purchaseController = purchaseController,
+ billing = billing,
+ receiptManagerFactory = { mockk(relaxed = true) },
+ track = {},
+ getSuperwallProducts = getSuperwallProducts,
+ )
+
+ @Test
+ fun `getProductsWithoutPaywall resolves a custom product via products when billing misses`() =
+ runTest {
+ Given("billing that doesn't know the id and a /products response that does") {
+ var productsCalls = 0
+ val manager =
+ storeManagerWith {
+ productsCalls++
+ Either.Success(customProductsResponse("custom_1"))
+ }
+ coEvery { billing.awaitGetProducts(any()) } returns emptySet()
+
+ When("getProductsWithoutPaywall is called for the custom id") {
+ val result = manager.getProductsWithoutPaywall(listOf("custom_1"))
+
+ Then("it resolves from /products as a custom product") {
+ assertEquals("custom_1", result["custom_1"]?.fullIdentifier)
+ junitAssertTrue(result["custom_1"]?.isCustomProduct == true)
+ }
+
+ And("/products was consulted exactly once") {
+ assertEquals(1, productsCalls)
+ }
+ }
+ }
+ }
+
@Test
fun `test getProducts in test mode returns partial test catalog when billing is unavailable`() =
runTest {
@@ -686,6 +747,30 @@ class StoreManagerTest {
}
}
+ @Test
+ fun `getProductsWithoutPaywall does not consult products when billing resolves everything`() =
+ runTest {
+ Given("billing that resolves the Play product") {
+ var productsCalls = 0
+ val manager =
+ storeManagerWith {
+ productsCalls++
+ Either.Success(SuperwallProductsResponse(emptyList()))
+ }
+ val play = mockk { every { fullIdentifier } returns "play_1" }
+ coEvery { billing.awaitGetProducts(any()) } returns setOf(play)
+
+ When("getProductsWithoutPaywall is called") {
+ val result = manager.getProductsWithoutPaywall(listOf("play_1"))
+
+ Then("the Play product is returned without a /products round-trip") {
+ assertEquals(play, result["play_1"])
+ assertEquals(0, productsCalls)
+ }
+ }
+ }
+ }
+
@Test
fun `test getProducts waits for the test catalog instead of racing activation`() =
runTest {
@@ -725,4 +810,53 @@ class StoreManagerTest {
}
}
}
+
+ @Test
+ fun `getProductsWithoutPaywall resolves a custom product even when billing is unavailable`() =
+ runTest {
+ Given("billing that throws and /products that has the custom product") {
+ val manager = storeManagerWith { Either.Success(customProductsResponse("custom_1")) }
+ coEvery { billing.awaitGetProducts(any()) } throws BillingError.BillingNotAvailable("nope")
+
+ When("getProductsWithoutPaywall is called for the custom id") {
+ val result = manager.getProductsWithoutPaywall(listOf("custom_1"))
+
+ Then("it still resolves the custom product without throwing") {
+ junitAssertTrue(result["custom_1"]?.isCustomProduct == true)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `fetchAndCacheCustomProducts rethrows on products failure when required`() =
+ runTest {
+ Given("a /products endpoint that fails") {
+ val manager = storeManagerWith { Either.Failure(NetworkError.Unknown(Exception("boom"))) }
+
+ When("fetchAndCacheCustomProducts is called with required = true") {
+ Then("it rethrows so the paywall can surface a product-load failure") {
+ assertThrows(NetworkError.Unknown::class.java) {
+ runBlocking { manager.fetchAndCacheCustomProducts(setOf("custom_1"), required = true) }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `fetchAndCacheCustomProducts swallows products failure when not required`() =
+ runTest {
+ Given("a /products endpoint that fails") {
+ val manager = storeManagerWith { Either.Failure(NetworkError.Unknown(Exception("boom"))) }
+
+ When("fetchAndCacheCustomProducts is called with required = false") {
+ manager.fetchAndCacheCustomProducts(setOf("custom_1"), required = false)
+
+ Then("it does not throw and caches nothing") {
+ assertNull(manager.getProductFromCache("custom_1"))
+ }
+ }
+ }
+ }
}
diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/store/abstractions/product/ApiStoreProductTest.kt
similarity index 78%
rename from superwall/src/test/java/com/superwall/sdk/store/testmode/TestStoreProductTest.kt
rename to superwall/src/test/java/com/superwall/sdk/store/abstractions/product/ApiStoreProductTest.kt
index 686609ace..c746e059f 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestStoreProductTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/abstractions/product/ApiStoreProductTest.kt
@@ -1,6 +1,6 @@
@file:Suppress("ktlint:standard:function-naming")
-package com.superwall.sdk.store.testmode
+package com.superwall.sdk.store.abstractions.product
import com.superwall.sdk.Given
import com.superwall.sdk.Then
@@ -20,7 +20,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import java.math.BigDecimal
-class TestStoreProductTest {
+class ApiStoreProductTest {
private fun makeProduct(
identifier: String = "com.test.product",
amountCents: Int = 999,
@@ -63,7 +63,7 @@ class TestStoreProductTest {
@Test
fun `price converts from cents correctly`() {
Given("a product with price 999 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 999))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 999))
Then("price is 9.99") {
assertEquals(0, testProduct.price.compareTo(BigDecimal("9.99")))
@@ -74,7 +74,7 @@ class TestStoreProductTest {
@Test
fun `price converts zero cents`() {
Given("a product with price 0 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 0))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 0))
Then("price is 0.00") {
assertEquals(0, testProduct.price.compareTo(BigDecimal.ZERO))
@@ -85,7 +85,7 @@ class TestStoreProductTest {
@Test
fun `price converts large amount`() {
Given("a product with price 99999 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 99999))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 99999))
Then("price is 999.99") {
assertEquals(0, testProduct.price.compareTo(BigDecimal("999.99")))
@@ -96,7 +96,7 @@ class TestStoreProductTest {
@Test
fun `price converts single digit cents`() {
Given("a product with price 5 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 5))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 5))
Then("price is 0.05") {
assertEquals(0, testProduct.price.compareTo(BigDecimal("0.05")))
@@ -111,7 +111,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps daily correctly`() {
Given("a daily product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 1))
Then("subscription period is 1 day") {
val period = testProduct.subscriptionPeriod
@@ -125,7 +125,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps weekly correctly`() {
Given("a weekly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
Then("subscription period is 1 week") {
val period = testProduct.subscriptionPeriod
@@ -139,7 +139,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps monthly correctly`() {
Given("a monthly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("subscription period is 1 month") {
val period = testProduct.subscriptionPeriod
@@ -153,7 +153,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps yearly correctly`() {
Given("a yearly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("subscription period is 1 year") {
val period = testProduct.subscriptionPeriod
@@ -167,7 +167,7 @@ class TestStoreProductTest {
@Test
fun `subscription period respects periodCount`() {
Given("a product with 3 month period") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("subscription period has value 3") {
val period = testProduct.subscriptionPeriod
@@ -181,7 +181,7 @@ class TestStoreProductTest {
@Test
fun `subscription period is null for non-subscription product`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("subscriptionPeriod is null") {
assertNull(testProduct.subscriptionPeriod)
@@ -196,7 +196,7 @@ class TestStoreProductTest {
@Test
fun `free trial is detected correctly`() {
Given("a product with a 7-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
Then("hasFreeTrial is true") {
assertTrue(testProduct.hasFreeTrial)
@@ -207,7 +207,7 @@ class TestStoreProductTest {
@Test
fun `no free trial when trialPeriodDays is null`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("hasFreeTrial is false") {
assertFalse(testProduct.hasFreeTrial)
@@ -218,7 +218,7 @@ class TestStoreProductTest {
@Test
fun `no free trial when trialPeriodDays is zero`() {
Given("a product with 0-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 0))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 0))
Then("hasFreeTrial is false") {
assertFalse(testProduct.hasFreeTrial)
@@ -229,7 +229,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodText formats correctly`() {
Given("a product with a 7-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
Then("trialPeriodText is '7-day'") {
assertEquals("7-day", testProduct.trialPeriodText)
@@ -240,7 +240,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodText is empty when no trial`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("trialPeriodText is empty") {
assertEquals("", testProduct.trialPeriodText)
@@ -251,7 +251,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodEndDate is set when trial exists`() {
Given("a product with a 14-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 14))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 14))
Then("trialPeriodEndDate is not null and in the future") {
assertNotNull(testProduct.trialPeriodEndDate)
@@ -263,7 +263,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodEndDate is null when no trial`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("trialPeriodEndDate is null") {
assertNull(testProduct.trialPeriodEndDate)
@@ -274,7 +274,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodPrice is zero`() {
Given("a product with a trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
Then("trialPeriodPrice is zero") {
assertEquals(0, testProduct.trialPeriodPrice.compareTo(BigDecimal.ZERO))
@@ -289,7 +289,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodDays returns correct value`() {
Given("a product with a 30-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 30))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 30))
Then("trialPeriodDays is 30") {
assertEquals(30, testProduct.trialPeriodDays)
@@ -301,7 +301,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodWeeks calculates from days`() {
Given("a product with a 14-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 14))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 14))
Then("trialPeriodWeeks is 2") {
assertEquals(2, testProduct.trialPeriodWeeks)
@@ -313,7 +313,7 @@ class TestStoreProductTest {
@Test
fun `trial period values are zero when no trial`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("all trial period values are zero") {
assertEquals(0, testProduct.trialPeriodDays)
@@ -331,7 +331,7 @@ class TestStoreProductTest {
@Test
fun `product identifiers are correct`() {
Given("a product with identifier 'com.test.premium'") {
- val testProduct = TestStoreProduct(makeProduct(identifier = "com.test.premium"))
+ val testProduct = ApiStoreProduct(makeProduct(identifier = "com.test.premium"))
Then("fullIdentifier and productIdentifier are set") {
assertEquals("com.test.premium", testProduct.fullIdentifier)
@@ -347,7 +347,7 @@ class TestStoreProductTest {
@Test
fun `productType is subs for subscription products`() {
Given("a subscription product") {
- val testProduct = TestStoreProduct(makeProduct())
+ val testProduct = ApiStoreProduct(makeProduct())
Then("productType is 'subs'") {
assertEquals("subs", testProduct.productType)
@@ -358,7 +358,7 @@ class TestStoreProductTest {
@Test
fun `productType is inapp for non-subscription products`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("productType is 'inapp'") {
assertEquals("inapp", testProduct.productType)
@@ -373,7 +373,7 @@ class TestStoreProductTest {
@Test
fun `period string formats correctly for monthly`() {
Given("a monthly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("period is 'month'") {
assertEquals("month", testProduct.period)
@@ -384,7 +384,7 @@ class TestStoreProductTest {
@Test
fun `period string formats correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("period is 'year'") {
assertEquals("year", testProduct.period)
@@ -395,7 +395,7 @@ class TestStoreProductTest {
@Test
fun `period string formats correctly for weekly`() {
Given("a weekly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
Then("period is 'week'") {
assertEquals("week", testProduct.period)
@@ -406,7 +406,7 @@ class TestStoreProductTest {
@Test
fun `period string for quarterly shows quarter`() {
Given("a 3-month subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("period is 'quarter'") {
assertEquals("quarter", testProduct.period)
@@ -417,7 +417,7 @@ class TestStoreProductTest {
@Test
fun `period string for semi-annual shows 6 months`() {
Given("a 6-month subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
Then("period is '6 months'") {
assertEquals("6 months", testProduct.period)
@@ -428,7 +428,7 @@ class TestStoreProductTest {
@Test
fun `period string for bi-monthly shows 2 months`() {
Given("a 2-month subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
Then("period is '2 months'") {
assertEquals("2 months", testProduct.period)
@@ -439,7 +439,7 @@ class TestStoreProductTest {
@Test
fun `period string for 7-day product shows week`() {
Given("a 7-day subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 7))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 7))
Then("period is 'week'") {
assertEquals("week", testProduct.period)
@@ -450,7 +450,7 @@ class TestStoreProductTest {
@Test
fun `period is empty for non-subscription product`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("period is empty") {
assertEquals("", testProduct.period)
@@ -465,7 +465,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats monthly correctly`() {
Given("a monthly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("periodly is 'monthly'") {
assertEquals("monthly", testProduct.periodly)
@@ -476,7 +476,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats yearly correctly`() {
Given("a yearly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodly is 'yearly'") {
assertEquals("yearly", testProduct.periodly)
@@ -487,7 +487,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats quarterly as every quarter`() {
Given("a quarterly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("periodly is 'quarterly'") {
assertEquals("quarterly", testProduct.periodly)
@@ -498,7 +498,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats semi-annual as every 6 months`() {
Given("a 6-month product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
Then("periodly is 'every 6 months'") {
assertEquals("every 6 months", testProduct.periodly)
@@ -509,7 +509,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats bi-monthly as every 2 months`() {
Given("a 2-month product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
Then("periodly is 'every 2 months'") {
assertEquals("every 2 months", testProduct.periodly)
@@ -524,7 +524,7 @@ class TestStoreProductTest {
@Test
fun `periodDays calculates correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodDays is 365") {
assertEquals(365, testProduct.periodDays)
@@ -536,7 +536,7 @@ class TestStoreProductTest {
@Test
fun `periodDays calculates correctly for monthly`() {
Given("a monthly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("periodDays is 30") {
assertEquals(30, testProduct.periodDays)
@@ -547,7 +547,7 @@ class TestStoreProductTest {
@Test
fun `periodDays calculates correctly for weekly`() {
Given("a weekly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
Then("periodDays is 7") {
assertEquals(7, testProduct.periodDays)
@@ -558,7 +558,7 @@ class TestStoreProductTest {
@Test
fun `periodWeeks calculates correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodWeeks is 52") {
assertEquals(52, testProduct.periodWeeks)
@@ -570,7 +570,7 @@ class TestStoreProductTest {
@Test
fun `periodMonths calculates correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodMonths is 12") {
assertEquals(12, testProduct.periodMonths)
@@ -582,7 +582,7 @@ class TestStoreProductTest {
@Test
fun `periodYears is 1 for yearly product`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodYears is 1") {
assertEquals(1, testProduct.periodYears)
@@ -594,7 +594,7 @@ class TestStoreProductTest {
@Test
fun `period calculations are zero for non-subscription product`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("all period values are 0") {
assertEquals(0, testProduct.periodDays)
@@ -612,7 +612,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod for monthly`() {
Given("a monthly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("localizedSubscriptionPeriod is '1 month'") {
assertEquals("1 month", testProduct.localizedSubscriptionPeriod)
@@ -623,7 +623,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod for plural months`() {
Given("a 3-month product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("localizedSubscriptionPeriod is '3 months'") {
assertEquals("3 months", testProduct.localizedSubscriptionPeriod)
@@ -634,7 +634,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod for yearly`() {
Given("a yearly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("localizedSubscriptionPeriod is '1 year'") {
assertEquals("1 year", testProduct.localizedSubscriptionPeriod)
@@ -645,7 +645,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod is empty for non-subscription`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("localizedSubscriptionPeriod is empty") {
assertEquals("", testProduct.localizedSubscriptionPeriod)
@@ -660,7 +660,7 @@ class TestStoreProductTest {
@Test
fun `currencyCode is set from product`() {
Given("a product with EUR currency") {
- val testProduct = TestStoreProduct(makeProduct(currency = "EUR"))
+ val testProduct = ApiStoreProduct(makeProduct(currency = "EUR"))
Then("currencyCode is EUR") {
assertEquals("EUR", testProduct.currencyCode)
@@ -671,7 +671,7 @@ class TestStoreProductTest {
@Test
fun `currencySymbol resolves for USD`() {
Given("a product with USD currency") {
- val testProduct = TestStoreProduct(makeProduct(currency = "USD"))
+ val testProduct = ApiStoreProduct(makeProduct(currency = "USD"))
Then("currencySymbol is not null and contains dollar sign") {
assertNotNull(testProduct.currencySymbol)
@@ -683,7 +683,7 @@ class TestStoreProductTest {
@Test
fun `currencySymbol resolves for EUR`() {
Given("a product with EUR currency") {
- val testProduct = TestStoreProduct(makeProduct(currency = "EUR"))
+ val testProduct = ApiStoreProduct(makeProduct(currency = "EUR"))
Then("currencySymbol is euro sign") {
// The euro sign can vary by locale, just verify it's not null
@@ -699,7 +699,7 @@ class TestStoreProductTest {
@Test
fun `attributes map contains all expected keys`() {
Given("a subscription product with trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
When("attributes is accessed") {
val attrs = testProduct.attributes
@@ -727,7 +727,7 @@ class TestStoreProductTest {
@Test
fun `attributes identifier matches product`() {
Given("a product with identifier 'com.test.pro'") {
- val testProduct = TestStoreProduct(makeProduct(identifier = "com.test.pro"))
+ val testProduct = ApiStoreProduct(makeProduct(identifier = "com.test.pro"))
Then("attributes identifier is correct") {
assertEquals("com.test.pro", testProduct.attributes["identifier"])
diff --git a/superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt b/superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt
new file mode 100644
index 000000000..5f29198a3
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt
@@ -0,0 +1,69 @@
+@file:Suppress("ktlint:standard:function-naming")
+
+package com.superwall.sdk.store.abstractions.transactions
+
+import com.superwall.sdk.Given
+import com.superwall.sdk.Then
+import com.superwall.sdk.When
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.util.Date
+
+class CustomStoreTransactionTest {
+ @Test
+ fun `custom-transaction constructor sets ids and skips SK2 fields`() {
+ Given("a pre-generated transaction id, product id and purchase date") {
+ val txnId = "ABC-123-CUSTOM"
+ val productId = "stripe_pro_monthly"
+ val purchaseDate = Date(1_700_000_000_000L)
+
+ When("a StoreTransaction is built via the custom constructor") {
+ val tx =
+ StoreTransaction(
+ customTransactionId = txnId,
+ productIdentifier = productId,
+ purchaseDate = purchaseDate,
+ configRequestId = "req-1",
+ appSessionId = "sess-1",
+ )
+
+ Then("originalTransactionIdentifier and storeTransactionId equal the custom id") {
+ assertEquals(txnId, tx.originalTransactionIdentifier)
+ assertEquals(txnId, tx.storeTransactionId)
+ }
+
+ Then("transaction date and original date both equal the purchase date") {
+ assertEquals(purchaseDate, tx.transactionDate)
+ assertEquals(purchaseDate, tx.originalTransactionDate)
+ }
+
+ Then("state is Purchased") {
+ assertEquals(StoreTransactionState.Purchased, tx.state)
+ }
+
+ Then("payment carries the product identifier") {
+ assertEquals(productId, tx.payment?.productIdentifier)
+ }
+
+ Then("SK2 / Play-specific fields are nullable empties") {
+ assertNull(tx.webOrderLineItemID)
+ assertNull(tx.appBundleId)
+ assertNull(tx.subscriptionGroupId)
+ assertNull(tx.isUpgraded)
+ assertNull(tx.expirationDate)
+ assertNull(tx.offerId)
+ assertNull(tx.revocationDate)
+ assertNull(tx.appAccountToken)
+ assertEquals("", tx.purchaseToken)
+ assertNull(tx.signature)
+ }
+
+ Then("config and session ids are preserved") {
+ assertEquals("req-1", tx.configRequestId)
+ assertEquals("sess-1", tx.appSessionId)
+ }
+ }
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt b/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt
index 20d5107fd..809f4040f 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt
@@ -58,6 +58,32 @@ class SuperwallProductSerializationTest {
}
}
+ @Test
+ fun `deserializes product with platform custom`() {
+ Given("a JSON product with platform custom") {
+ val jsonString =
+ """
+ {
+ "identifier": "stripe_pro_monthly",
+ "platform": "custom",
+ "price": { "amount": 1499, "currency": "USD" },
+ "subscription": { "period": "month", "period_count": 1, "trial_period_days": 14 }
+ }
+ """.trimIndent()
+
+ When("the product is deserialized") {
+ val product = json.decodeFromString(jsonString)
+
+ Then("platform is CUSTOM and fields are preserved") {
+ assertEquals("stripe_pro_monthly", product.identifier)
+ assertEquals(SuperwallProductPlatform.CUSTOM, product.platform)
+ assertEquals(1499, product.price!!.amount)
+ assertEquals(14, product.subscription!!.trialPeriodDays)
+ }
+ }
+ }
+ }
+
@Test
fun `deserializes product without subscription`() {
Given("a JSON product without subscription field") {
diff --git a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
index 5f57ed892..c3a2888c1 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
@@ -494,7 +494,7 @@ class TransactionManagerTest {
val product = mockStoreProduct(productId, rawProduct)
every { storeManager.getProductFromCache(productId) } returns product
coEvery {
- storeManager.purchaseController.purchase(any(), any(), any(), any())
+ storeManager.purchaseController.purchase(any(), any(), any(), any())
} returns PurchaseResult.Purchased()
every { factory.makeHasExternalPurchaseController() } returns true
every { factory.makeTransactionVerifier() } returns
diff --git a/version.env b/version.env
index 7e3df4474..adafaa0b1 100644
--- a/version.env
+++ b/version.env
@@ -1 +1 @@
-SUPERWALL_VERSION=2.7.23
+SUPERWALL_VERSION=2.8.0