From 58c6733c6a3878cf7f7a1aced988d2969ec9fa08 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 11 May 2026 15:18:00 +0200 Subject: [PATCH 01/21] Add custom store products, new purchase controller method --- .../PurchaseController.kt | 22 ++++ .../sdk/dependencies/DependencyContainer.kt | 13 ++ .../sdk/dependencies/FactoryProtocols.kt | 11 ++ .../superwall/sdk/models/paywall/Paywall.kt | 3 + .../models/product/CrossplatformProduct.kt | 46 +++++++ .../sdk/models/product/ProductItem.kt | 30 +++++ .../sdk/paywall/request/PaywallLogic.kt | 14 +++ .../paywall/request/PaywallRequestManager.kt | 53 ++++++++ .../sdk/store/InternalPurchaseController.kt | 11 ++ .../product/ApiStoreProduct.kt} | 12 +- .../abstractions/product/StoreProduct.kt | 18 +++ .../transactions/StoreTransaction.kt | 37 ++++++ .../superwall/sdk/store/testmode/TestMode.kt | 3 +- .../store/testmode/models/SuperwallProduct.kt | 3 + .../store/transactions/TransactionManager.kt | 117 ++++++++++++++++++ .../models/product/CustomStoreProductTest.kt | 88 +++++++++++++ .../sdk/paywall/request/PaywallLogicTest.kt | 92 ++++++++++++++ .../product/ApiStoreProductTest.kt} | 110 ++++++++-------- .../CustomStoreTransactionTest.kt | 69 +++++++++++ .../SuperwallProductSerializationTest.kt | 26 ++++ 20 files changed, 717 insertions(+), 61 deletions(-) rename superwall/src/main/java/com/superwall/sdk/store/{testmode/TestStoreProduct.kt => abstractions/product/ApiStoreProduct.kt} (97%) create mode 100644 superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt rename superwall/src/test/java/com/superwall/sdk/store/{testmode/TestStoreProductTest.kt => abstractions/product/ApiStoreProductTest.kt} (78%) create mode 100644 superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt 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..86a2a5ad9 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. @@ -51,4 +52,25 @@ interface PurchaseController { */ @MainThread suspend fun restorePurchases(): RestorationResult + + /** + * Called when the user initiates purchasing of a **custom** product — a product whose + * `store` is `CUSTOM` and whose metadata is sourced from the Superwall API rather than + * Google Play. Implement this to route the purchase through your own payment system + * (e.g. Stripe, web checkout). + * + * The default implementation returns `PurchaseResult.Failed` to signal that the + * controller does not handle custom products. Override it if you've configured any + * custom products in the Superwall dashboard. + * + * @param customProduct The Superwall [StoreProduct] (with `isCustomProduct == true`) the + * user would like to purchase. Its [StoreProduct.customTransactionId] is pre-generated + * by the SDK and used as the original transaction identifier in analytics. + */ + @MainThread + suspend fun purchase(customProduct: StoreProduct): PurchaseResult = + PurchaseResult.Failed( + "This PurchaseController does not implement purchase(customProduct:). " + + "Override it to handle custom (store == CUSTOM) products.", + ) } 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..0ebb74139 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -1073,6 +1073,19 @@ class DependencyContainer( appSessionId = appSessionManager.appSession.id, ) + override suspend fun makeStoreTransaction( + customTransactionId: String, + productIdentifier: String, + purchaseDate: java.util.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..0b2744e88 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -238,6 +238,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: java.util.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..8edbd1ec4 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,6 +452,7 @@ object CrossplatformProductSerializer : KSerializer { "APP_STORE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "STRIPE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "PADDLE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) + "CUSTOM" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "OTHER" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) else -> CrossplatformProduct.StoreProduct.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..48a8164bc 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 @@ -44,6 +44,9 @@ enum class Store { @SerialName("SUPERWALL") SUPERWALL, + @SerialName("CUSTOM") + CUSTOM, + @SerialName("OTHER") OTHER, @@ -57,6 +60,7 @@ enum class Store { "STRIPE" -> STRIPE "PADDLE" -> PADDLE "SUPERWALL" -> SUPERWALL + "CUSTOM" -> CUSTOM else -> OTHER } } @@ -147,6 +151,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 +284,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,6 +337,11 @@ object StoreProductSerializer : KSerializer { ProductItem.StoreProductType.Paddle(product) } + Store.CUSTOM -> { + val product = json.decodeFromJsonElement(CustomStoreProduct.serializer(), jsonObject) + ProductItem.StoreProductType.Custom(product) + } + Store.SUPERWALL, Store.OTHER, -> { @@ -366,6 +389,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 +407,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 +476,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..032b09d44 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,6 +161,20 @@ 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, + ) + } } } 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..18d5e6cf6 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 @@ -14,14 +14,17 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.map import com.superwall.sdk.misc.mapError import com.superwall.sdk.misc.onError +import com.superwall.sdk.misc.fold 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 import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.store.abstractions.product.ApiStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CompletableDeferred @@ -297,6 +300,9 @@ class PaywallRequestManager( var paywall = paywall paywall = trackProductsLoadStart(paywall, request) + // Fetch and cache custom products (store == CUSTOM) before Google Play product fetch. + // These are sourced from the Superwall /products endpoint, not from Play Billing. + fetchAndCacheCustomProducts(paywall) paywall = try { getProducts(paywall, request) @@ -308,6 +314,53 @@ class PaywallRequestManager( 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. + */ + private suspend fun fetchAndCacheCustomProducts(paywall: Paywall) { + val customIds = + paywall.productItems + .filter { it.type is ProductItem.StoreProductType.Custom } + .map { it.fullProductId } + .filterNot { it.isEmpty() } + .toSet() + if (customIds.isEmpty()) return + + val idsNeedingRefresh = customIds.filterNot { storeManager.hasCached(it) } + if (idsNeedingRefresh.isEmpty()) return + + network.getSuperwallProducts().fold( + onSuccess = { response -> + val matches = response.data.filter { it.identifier in idsNeedingRefresh } + val seenIds = mutableSetOf() + for (superwallProduct in matches) { + if (!seenIds.add(superwallProduct.identifier)) { + Logger.debug( + LogLevel.warn, + LogScope.productsManager, + "Duplicate custom product id from /products: ${superwallProduct.identifier}", + ) + continue + } + val apiProduct = ApiStoreProduct(superwallProduct) + val storeProduct = StoreProduct.custom(apiProduct) + storeManager.cacheProduct(superwallProduct.identifier, storeProduct) + } + }, + onFailure = { error -> + Logger.debug( + LogLevel.error, + LogScope.productsManager, + "Failed to fetch custom products: ${error.message}", + ) + }, + ) + } + private suspend fun getProducts( paywall: Paywall, request: PaywallRequest, 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..e1484a790 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 @@ -61,6 +62,16 @@ class InternalPurchaseController( // return PurchaseResult.Cancelled() } + override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { + if (kotlinPurchaseController != null) { + return kotlinPurchaseController.purchase(customProduct) + } + // No PurchaseControllerJava overload for custom products yet — callers should + // be guarded by TransactionManager against this path when no external controller + // is configured. + return PurchaseResult.Failed("No PurchaseController configured to handle custom product purchase.") + } + override suspend fun restorePurchases(): RestorationResult { if (kotlinPurchaseController != null) { return kotlinPurchaseController.restorePurchases() diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt similarity index 97% rename from superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt rename to superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt index a69e34aad..2bd51b1ca 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt @@ -1,8 +1,5 @@ -package com.superwall.sdk.store.testmode +package com.superwall.sdk.store.abstractions.product -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.testmode.models.SuperwallProduct import com.superwall.sdk.store.testmode.models.SuperwallSubscriptionPeriod import com.superwall.sdk.utilities.DateUtils @@ -13,7 +10,12 @@ import java.util.Calendar import java.util.Date import java.util.Locale -class TestStoreProduct( +/** + * 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. + */ +class ApiStoreProduct( private val superwallProduct: SuperwallProduct, ) : StoreProductType { private val priceFormatterProvider = PriceFormatterProvider() 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..f86cdb689 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 @@ -78,8 +78,26 @@ sealed class OfferType { class StoreProduct private constructor( val rawStoreProduct: RawStoreProduct?, private val backingProduct: StoreProductType, + val isCustomProduct: Boolean = false, ) : StoreProductType by backingProduct { constructor(rawStoreProduct: RawStoreProduct) : this(rawStoreProduct, rawStoreProduct) constructor(storeProductType: StoreProductType) : this(null, storeProductType) + + /** + * Pre-generated transaction identifier used as the original transaction ID when + * a custom product (store == CUSTOM) is purchased through an external + * PurchaseController. Regenerated by TransactionManager on each purchase attempt. + */ + var customTransactionId: String? = null + + companion object { + /** Builds a StoreProduct flagged as a custom product, backed by an ApiStoreProduct. */ + fun custom(apiStoreProduct: ApiStoreProduct): StoreProduct = + StoreProduct( + rawStoreProduct = null, + backingProduct = apiStoreProduct, + isCustomProduct = true, + ) + } } 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/TestMode.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt index cef1689fa..2699e365f 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt @@ -18,6 +18,7 @@ import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.StoredTestModeSettings import com.superwall.sdk.storage.TestModeSettings import com.superwall.sdk.store.Entitlements +import com.superwall.sdk.store.abstractions.product.ApiStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.testmode.models.SuperwallEntitlementRef import com.superwall.sdk.store.testmode.models.SuperwallProduct @@ -326,7 +327,7 @@ class TestMode( val productsByFullId = androidProducts.associate { superwallProduct -> - val testProduct = TestStoreProduct(superwallProduct) + val testProduct = ApiStoreProduct(superwallProduct) superwallProduct.identifier to StoreProduct(testProduct) } setTestProducts(productsByFullId) 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..b0a1b9d3c 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 @@ -354,6 +354,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}") @@ -435,6 +441,117 @@ 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(customProduct:)], 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 products require an external PurchaseController. " + + "Configure Superwall with a PurchaseController that overrides " + + "purchase(customProduct:) to handle ${product.fullIdentifier}." + log(message = message, error = Error(message)) + trackFailure(message, product, purchaseSource) + if (purchaseSource is PurchaseSource.Internal) { + updateState( + purchaseSource.paywallInfo.cacheKey, + PaywallViewState.Updates.ToggleSpinner(hidden = true), + ) + } + return PurchaseResult.Failed(message) + } + + // Regenerate the transaction id on every attempt so cancel-and-retry + // doesn't reuse the same identifier in analytics. + product.customTransactionId = java.util.UUID.randomUUID().toString() + + prepareToPurchase(product, purchaseSource) + + val result = storeManager.purchaseController.purchase(customProduct = product) + + // If we only have an external PurchaseController, the dev's flow handles the + // rest of the transaction lifecycle (mirrors the existing ExternalPurchase + // early return below for Play products). + if (purchaseSource is PurchaseSource.ExternalPurchase && + factory.makeHasExternalPurchaseController() && + !factory.makeHasInternalPurchaseController() + ) { + if (result is PurchaseResult.Purchased) { + // Still build + track a transaction so analytics include the custom txn id. + val transaction = + factory.makeStoreTransaction( + customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + productIdentifier = product.fullIdentifier, + purchaseDate = java.util.Date(), + ) + trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + } + return result + } + + when (result) { + is PurchaseResult.Purchased -> { + val transaction = + factory.makeStoreTransaction( + customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + productIdentifier = product.fullIdentifier, + purchaseDate = java.util.Date(), + ) + // Skip storeManager.loadPurchasedProducts — no Play receipt for custom products. + trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + 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/models/product/CustomStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt new file mode 100644 index 000000000..f124bd5d7 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt @@ -0,0 +1,88 @@ +@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 `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..c6b7f4d8e 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 = 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") { From 9816ab67f2144872cb11430c747b06a1cb951573 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 24 Jul 2026 15:11:53 +0200 Subject: [PATCH 02/21] Add CustomPurchaseController, fix referenecs to external controllers --- .../sdk/dependencies/DependencyContainer.kt | 8 +- .../sdk/dependencies/FactoryProtocols.kt | 12 +- .../sdk/paywall/request/PaywallLogic.kt | 24 ++++ .../paywall/request/PaywallRequestManager.kt | 62 +++----- .../sdk/store/AutomaticPurchaseController.kt | 29 +++- .../store/CustomProductPurchaseController.kt | 24 ++++ .../sdk/store/InternalPurchaseController.kt | 14 +- .../com/superwall/sdk/store/StoreManager.kt | 91 +++++++++++- .../abstractions/product/StoreProduct.kt | 7 +- .../store/transactions/TransactionManager.kt | 93 ++++++++---- .../sdk/paywall/request/PaywallLogicTest.kt | 79 +++++++++++ .../store/InternalPurchaseControllerTest.kt | 66 +++++++++ .../superwall/sdk/store/StoreManagerTest.kt | 134 ++++++++++++++++++ 13 files changed, 561 insertions(+), 82 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt 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 0ebb74139..20a27a866 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, ) @@ -943,6 +944,9 @@ class DependencyContainer( override fun makeHasExternalPurchaseController(): Boolean = storeManager.purchaseController.hasExternalPurchaseController + override fun makeHasCustomProductPurchaseController(): Boolean = + storeManager.purchaseController.hasCustomProductPurchaseController + override fun makeHasInternalPurchaseController(): Boolean = storeManager.purchaseController.hasInternalPurchaseController @@ -1076,7 +1080,7 @@ class DependencyContainer( override suspend fun makeStoreTransaction( customTransactionId: String, productIdentifier: String, - purchaseDate: java.util.Date, + purchaseDate: Date, ): StoreTransaction = StoreTransaction( customTransactionId = customTransactionId, 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 0b2744e88..8a12f0300 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: @@ -189,6 +190,15 @@ interface UserAttributesEventFactory { interface HasExternalPurchaseControllerFactory { fun makeHasExternalPurchaseController(): Boolean + + /** + * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases. + * Defaults to [makeHasExternalPurchaseController] since a fully external controller may + * override purchase(customProduct:); a CustomProductPurchaseController reports true here + * while reporting false for makeHasExternalPurchaseController (Superwall still manages the + * standard Play lifecycle for it). + */ + fun makeHasCustomProductPurchaseController(): Boolean = makeHasExternalPurchaseController() } interface HasInternalPurchaseControllerFactory { @@ -246,7 +256,7 @@ interface StoreTransactionFactory { suspend fun makeStoreTransaction( customTransactionId: String, productIdentifier: String, - purchaseDate: java.util.Date, + purchaseDate: Date, ): StoreTransaction suspend fun activeProductIds(): List 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 032b09d44..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 @@ -178,6 +178,30 @@ object PaywallLogic { } } + /** + * 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 18d5e6cf6..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 @@ -14,7 +14,6 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.map import com.superwall.sdk.misc.mapError import com.superwall.sdk.misc.onError -import com.superwall.sdk.misc.fold import com.superwall.sdk.misc.then import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.events.EventData @@ -24,7 +23,6 @@ import com.superwall.sdk.network.Network import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.request.ProductOverride import com.superwall.sdk.store.StoreManager -import com.superwall.sdk.store.abstractions.product.ApiStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CompletableDeferred @@ -300,15 +298,22 @@ class PaywallRequestManager( var paywall = paywall paywall = trackProductsLoadStart(paywall, request) - // Fetch and cache custom products (store == CUSTOM) before Google Play product fetch. - // These are sourced from the Superwall /products endpoint, not from Play Billing. - fetchAndCacheCustomProducts(paywall) - 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 @@ -319,46 +324,19 @@ class PaywallRequestManager( * /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. + * 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 } - .filterNot { it.isEmpty() } .toSet() if (customIds.isEmpty()) return - val idsNeedingRefresh = customIds.filterNot { storeManager.hasCached(it) } - if (idsNeedingRefresh.isEmpty()) return - - network.getSuperwallProducts().fold( - onSuccess = { response -> - val matches = response.data.filter { it.identifier in idsNeedingRefresh } - val seenIds = mutableSetOf() - for (superwallProduct in matches) { - if (!seenIds.add(superwallProduct.identifier)) { - Logger.debug( - LogLevel.warn, - LogScope.productsManager, - "Duplicate custom product id from /products: ${superwallProduct.identifier}", - ) - continue - } - val apiProduct = ApiStoreProduct(superwallProduct) - val storeProduct = StoreProduct.custom(apiProduct) - storeManager.cacheProduct(superwallProduct.identifier, storeProduct) - } - }, - onFailure = { error -> - Logger.debug( - LogLevel.error, - LogScope.productsManager, - "Failed to fetch custom products: ${error.message}", - ) - }, - ) + storeManager.fetchAndCacheCustomProducts(customIds, required = true) } private suspend fun getProducts( 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..83f08d3e9 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 { @@ -270,6 +271,26 @@ class AutomaticPurchaseController( return value } + /** + * The automatic controller only handles Google Play products. Custom (store == CUSTOM) + * products require a [CustomProductPurchaseController] (or a PurchaseController that + * overrides purchase(customProduct:)). TransactionManager guards against reaching this in + * the normal flow; this override logs and fails clearly as a safety net. + */ + override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { + val message = + "AutomaticPurchaseController cannot purchase custom (store == CUSTOM) product " + + "'${customProduct.fullIdentifier}'. Configure Superwall with a " + + "CustomProductPurchaseController (or a PurchaseController that overrides " + + "purchase(customProduct:)) to handle custom products." + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.nativePurchaseController, + message = message, + ) + return PurchaseResult.Failed(message) + } + override suspend fun restorePurchases(): RestorationResult { syncSubscriptionStatusAndWait() return RestorationResult.Restored() @@ -349,7 +370,7 @@ class AutomaticPurchaseController( it.products }.toSet() .flatMap { - val res = entitlementsInfo.byProductId(it) + val res = entitlementsInfo().byProductId(it) res }.toSet() .let { entitlements -> @@ -359,7 +380,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/CustomProductPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt new file mode 100644 index 000000000..f416e3185 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt @@ -0,0 +1,24 @@ +package com.superwall.sdk.store + +import android.content.Context +import com.superwall.sdk.delegate.PurchaseResult +import com.superwall.sdk.delegate.subscription_controller.PurchaseController +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.store.abstractions.product.StoreProduct + +/** + * Use this controller as a [PurchaseController] in [com.superwall.sdk.Superwall.configure] if you: + * - Want to use custom store products + * - Also want to keep other purchases going through superwall + */ +class CustomProductPurchaseController( + val appContext: Context, + val onPurchase: (customProduct: StoreProduct)-> PurchaseResult +) : PurchaseController by AutomaticPurchaseController( + appContext, + IOScope() +){ + override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { + return onPurchase(customProduct) + } +} 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 e1484a790..ec91ded8b 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -20,7 +20,19 @@ class InternalPurchaseController( get() = !hasInternalPurchaseController val hasInternalPurchaseController: Boolean - get() = kotlinPurchaseController is AutomaticPurchaseController + get() = + kotlinPurchaseController is AutomaticPurchaseController || + // Delegates standard purchases to the automatic controller, so Superwall still + // manages the Play lifecycle; only custom products route to the dev's lambda. + kotlinPurchaseController is CustomProductPurchaseController + + /** + * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases: + * a dedicated [CustomProductPurchaseController], or any fully external controller (which may + * override purchase(customProduct:)). + */ + val hasCustomProductPurchaseController: Boolean + get() = kotlinPurchaseController is CustomProductPurchaseController || hasExternalPurchaseController override suspend fun purchase( activity: Activity, 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/StoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt index f86cdb689..b451bbf94 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 @@ -85,9 +85,10 @@ class StoreProduct private constructor( constructor(storeProductType: StoreProductType) : this(null, storeProductType) /** - * Pre-generated transaction identifier used as the original transaction ID when - * a custom product (store == CUSTOM) is purchased through an external - * PurchaseController. Regenerated by TransactionManager on each purchase attempt. + * Pre-generated transaction id for the current custom-product (store == CUSTOM) purchase, + * exposed so the developer can read it inside `purchase(customProduct:)`. 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 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 b0a1b9d3c..5393faa25 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, @@ -453,46 +460,82 @@ class TransactionManager( purchaseSource: PurchaseSource, shouldDismiss: Boolean, ): PurchaseResult { - if (!factory.makeHasExternalPurchaseController()) { + if (!factory.makeHasCustomProductPurchaseController()) { val message = - "Custom products require an external PurchaseController. " + - "Configure Superwall with a PurchaseController that overrides " + - "purchase(customProduct:) to handle ${product.fullIdentifier}." + "Custom products require a PurchaseController that handles them. Configure " + + "Superwall with a CustomProductPurchaseController (or a PurchaseController " + + "that overrides purchase(customProduct:)) to handle ${product.fullIdentifier}." log(message = message, error = Error(message)) trackFailure(message, product, purchaseSource) if (purchaseSource is PurchaseSource.Internal) { - updateState( - purchaseSource.paywallInfo.cacheKey, - PaywallViewState.Updates.ToggleSpinner(hidden = true), - ) + // 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) } - // Regenerate the transaction id on every attempt so cancel-and-retry - // doesn't reuse the same identifier in analytics. - product.customTransactionId = java.util.UUID.randomUUID().toString() + // 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 result = storeManager.purchaseController.purchase(customProduct = product) - // If we only have an external PurchaseController, the dev's flow handles the - // rest of the transaction lifecycle (mirrors the existing ExternalPurchase - // early return below for Play products). - if (purchaseSource is PurchaseSource.ExternalPurchase && - factory.makeHasExternalPurchaseController() && - !factory.makeHasInternalPurchaseController() - ) { + // 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) { - // Still build + track a transaction so analytics include the custom txn id. val transaction = factory.makeStoreTransaction( - customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + customTransactionId = txnId, productIdentifier = product.fullIdentifier, - purchaseDate = java.util.Date(), + purchaseDate = Date(), ) - trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial) } return result } @@ -501,12 +544,12 @@ class TransactionManager( is PurchaseResult.Purchased -> { val transaction = factory.makeStoreTransaction( - customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + customTransactionId = txnId, productIdentifier = product.fullIdentifier, - purchaseDate = java.util.Date(), + purchaseDate = Date(), ) // Skip storeManager.loadPurchasedProducts — no Play receipt for custom products. - trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial) if (shouldDismiss && purchaseSource is PurchaseSource.Internal && factory.makeSuperwallOptions().paywalls.automaticallyDismiss 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 c6b7f4d8e..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 @@ -703,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/store/InternalPurchaseControllerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt index f172ec1c0..64da611e0 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,72 @@ class InternalPurchaseControllerTest { } } + @Test + fun `test CustomProductPurchaseController is treated as internal, not external`() = + runTest { + Given("an InternalPurchaseController wrapping a CustomProductPurchaseController") { + val customProductController = mockk() + val controller = + InternalPurchaseController( + kotlinPurchaseController = customProductController, + javaPurchaseController = null, + context = mockContext, + ) + + When("checking the controller flags") { + Then("it is internal — Superwall manages the standard Play lifecycle") { + assertTrue(controller.hasInternalPurchaseController) + assertFalse(controller.hasExternalPurchaseController) + } + + And("it can still fulfill custom product purchases") { + assertTrue(controller.hasCustomProductPurchaseController) + } + } + } + } + + @Test + fun `test a fully external controller can fulfill custom products`() = + runTest { + Given("an InternalPurchaseController with a generic external controller") { + val externalController = mockk() + val controller = + InternalPurchaseController( + kotlinPurchaseController = externalController, + javaPurchaseController = null, + context = mockContext, + ) + + When("checking hasCustomProductPurchaseController") { + Then("it is true — the controller may override purchase(customProduct:)") { + assertTrue(controller.hasExternalPurchaseController) + assertTrue(controller.hasCustomProductPurchaseController) + } + } + } + } + + @Test + fun `test automatic controller cannot fulfill custom products`() = + runTest { + Given("an InternalPurchaseController with an AutomaticPurchaseController") { + val automaticController = mockk() + val controller = + InternalPurchaseController( + kotlinPurchaseController = automaticController, + javaPurchaseController = null, + context = mockContext, + ) + + When("checking hasCustomProductPurchaseController") { + Then("it is false — custom purchases are rejected") { + assertFalse(controller.hasCustomProductPurchaseController) + } + } + } + } + @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")) + } + } + } + } } From 8c69af081da06b325f60c3e94d4cd4b8c8dfc1aa Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 24 Jul 2026 15:42:48 +0200 Subject: [PATCH 03/21] Add storefront test --- .../test/java/com/superwall/sdk/billing/StorefrontTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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, From 62e0de54ec97aad455ff64b3a452c748dd6173aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Jul 2026 13:59:57 +0000 Subject: [PATCH 04/21] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index cf88d0552..95275ca45 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches35.2% \ No newline at end of file +branches36.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index b9dcd7843..0ab08157f 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage44.7% \ No newline at end of file +coverage45.2% \ No newline at end of file From cae6f3ac5ac8bb02a1ff21d05a25094dc8c6d82c Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 10:50:37 +0200 Subject: [PATCH 05/21] Update Google Play Billing Library to 9.1.0 Bumps the billing client from 8.0.0 to 9.1.0 and RevenueCat (test app) to 10.14.1. Billing 9 removes the SkuDetails APIs, so this drops the deprecated SuperwallBillingFlowParams.Builder.setSkuDetails and the unused internal SWProduct wrapper, and switches buildQueryPurchaseHistoryParams over to QueryPurchasesParams. See https://developer.android.com/google/play/billing/migrate-gpblv9 --- CHANGELOG.md | 11 +++++++++++ gradle/libs.versions.toml | 4 ++-- .../sdk/billing/BillingClientParamBuilders.kt | 5 ++--- .../main/java/com/superwall/sdk/billing/SWProduct.kt | 7 ------- .../billing/observer/SuperwallBillingFlowParams.kt | 7 ------- version.env | 2 +- 6 files changed, 16 insertions(+), 20 deletions(-) delete mode 100644 superwall/src/main/java/com/superwall/sdk/billing/SWProduct.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 05dc905d1..d193367a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 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. + +## Breaking Changes +- 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`. +- **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. + ## 2.7.22 ## Enhancements 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/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/version.env b/version.env index e457ae8fd..adafaa0b1 100644 --- a/version.env +++ b/version.env @@ -1 +1 @@ -SUPERWALL_VERSION=2.7.22 +SUPERWALL_VERSION=2.8.0 From 87a13e4167c06704e942bf2daff0b29d385c7d28 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 17:49:52 +0200 Subject: [PATCH 06/21] Deprecate old purchase controller method --- .../purchase/RevenueCatPurchaseController.kt | 27 +++++- .../sdk/config/options/SuperwallOptions.kt | 2 +- .../PurchaseController.kt | 80 ++++++++++++----- .../sdk/dependencies/DependencyContainer.kt | 3 - .../sdk/dependencies/FactoryProtocols.kt | 9 -- .../models/product/CrossplatformProduct.kt | 11 ++- .../sdk/models/product/ProductItem.kt | 36 +++++++- .../sdk/store/AutomaticPurchaseController.kt | 27 ++---- .../store/CustomProductPurchaseController.kt | 24 ----- .../sdk/store/InternalPurchaseController.kt | 87 +++++++++---------- .../abstractions/product/ApiStoreProduct.kt | 5 ++ .../abstractions/product/StoreProduct.kt | 15 +++- .../store/transactions/TransactionManager.kt | 27 ++++-- .../models/product/CustomStoreProductTest.kt | 85 ++++++++++++++++++ .../store/InternalPurchaseControllerTest.kt | 63 ++++++-------- .../transactions/TransactionManagerTest.kt | 2 +- 16 files changed, 318 insertions(+), 185 deletions(-) delete mode 100644 superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt 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/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index abb2abda7..5124f69c3 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -27,7 +27,7 @@ class SuperwallOptions() { open val hostDomain: String, ) { open val baseHost: String - get() = "api.$hostDomain" + get() = "ir-feat-custom-store-and.prd.us-east-1.review-lab.superwall-services.com" open val collectorHost: String get() = "collector.$hostDomain" 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 86a2a5ad9..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 @@ -27,50 +27,84 @@ 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 a restore. + * Called when the user initiates purchasing of a product. * - * Add your restore logic here, making sure that the user's subscription status is updated after restore, - * and return its result. + * 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)). * - * @return A `RestorationResult` that's `Restored` if the user's purchases were restored or `Failed(error)` if they weren't. - * **Note**: `Restored` does not imply the user has an active subscription, it just means the restore had no errors. + * 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 restorePurchases(): RestorationResult + 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 purchasing of a **custom** product — a product whose - * `store` is `CUSTOM` and whose metadata is sourced from the Superwall API rather than - * Google Play. Implement this to route the purchase through your own payment system - * (e.g. Stripe, web checkout). + * Called when the user initiates a restore. * - * The default implementation returns `PurchaseResult.Failed` to signal that the - * controller does not handle custom products. Override it if you've configured any - * custom products in the Superwall dashboard. + * Add your restore logic here, making sure that the user's subscription status is updated after restore, + * and return its result. * - * @param customProduct The Superwall [StoreProduct] (with `isCustomProduct == true`) the - * user would like to purchase. Its [StoreProduct.customTransactionId] is pre-generated - * by the SDK and used as the original transaction identifier in analytics. + * @return A `RestorationResult` that's `Restored` if the user's purchases were restored or `Failed(error)` if they weren't. + * **Note**: `Restored` does not imply the user has an active subscription, it just means the restore had no errors. */ @MainThread - suspend fun purchase(customProduct: StoreProduct): PurchaseResult = - PurchaseResult.Failed( - "This PurchaseController does not implement purchase(customProduct:). " + - "Override it to handle custom (store == CUSTOM) products.", - ) + suspend fun restorePurchases(): RestorationResult } 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 20a27a866..86d047bee 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -944,9 +944,6 @@ class DependencyContainer( override fun makeHasExternalPurchaseController(): Boolean = storeManager.purchaseController.hasExternalPurchaseController - override fun makeHasCustomProductPurchaseController(): Boolean = - storeManager.purchaseController.hasCustomProductPurchaseController - override fun makeHasInternalPurchaseController(): Boolean = storeManager.purchaseController.hasInternalPurchaseController 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 8a12f0300..8f597ca53 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -190,15 +190,6 @@ interface UserAttributesEventFactory { interface HasExternalPurchaseControllerFactory { fun makeHasExternalPurchaseController(): Boolean - - /** - * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases. - * Defaults to [makeHasExternalPurchaseController] since a fully external controller may - * override purchase(customProduct:); a CustomProductPurchaseController reports true here - * while reporting false for makeHasExternalPurchaseController (Superwall still manages the - * standard Play lifecycle for it). - */ - fun makeHasCustomProductPurchaseController(): Boolean = makeHasExternalPurchaseController() } interface HasInternalPurchaseControllerFactory { 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 8edbd1ec4..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 @@ -453,7 +453,16 @@ object CrossplatformProductSerializer : KSerializer { "STRIPE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "PADDLE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "CUSTOM" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) - "OTHER" -> 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 48a8164bc..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, @@ -66,6 +68,19 @@ enum class Store { } } +// 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( @@ -342,13 +357,26 @@ object StoreProductSerializer : KSerializer { ProductItem.StoreProductType.Custom(product) } - Store.SUPERWALL, - Store.OTHER, - -> { + 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) + } + } } } } 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 83f08d3e9..1c2f523c8 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt @@ -162,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, @@ -271,26 +278,6 @@ class AutomaticPurchaseController( return value } - /** - * The automatic controller only handles Google Play products. Custom (store == CUSTOM) - * products require a [CustomProductPurchaseController] (or a PurchaseController that - * overrides purchase(customProduct:)). TransactionManager guards against reaching this in - * the normal flow; this override logs and fails clearly as a safety net. - */ - override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { - val message = - "AutomaticPurchaseController cannot purchase custom (store == CUSTOM) product " + - "'${customProduct.fullIdentifier}'. Configure Superwall with a " + - "CustomProductPurchaseController (or a PurchaseController that overrides " + - "purchase(customProduct:)) to handle custom products." - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.nativePurchaseController, - message = message, - ) - return PurchaseResult.Failed(message) - } - override suspend fun restorePurchases(): RestorationResult { syncSubscriptionStatusAndWait() return RestorationResult.Restored() diff --git a/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt deleted file mode 100644 index f416e3185..000000000 --- a/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.superwall.sdk.store - -import android.content.Context -import com.superwall.sdk.delegate.PurchaseResult -import com.superwall.sdk.delegate.subscription_controller.PurchaseController -import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.store.abstractions.product.StoreProduct - -/** - * Use this controller as a [PurchaseController] in [com.superwall.sdk.Superwall.configure] if you: - * - Want to use custom store products - * - Also want to keep other purchases going through superwall - */ -class CustomProductPurchaseController( - val appContext: Context, - val onPurchase: (customProduct: StoreProduct)-> PurchaseResult -) : PurchaseController by AutomaticPurchaseController( - appContext, - IOScope() -){ - override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { - return onPurchase(customProduct) - } -} 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 ec91ded8b..5a79661a7 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -20,68 +20,61 @@ class InternalPurchaseController( get() = !hasInternalPurchaseController val hasInternalPurchaseController: Boolean - get() = - kotlinPurchaseController is AutomaticPurchaseController || - // Delegates standard purchases to the automatic controller, so Superwall still - // manages the Play lifecycle; only custom products route to the dev's lambda. - kotlinPurchaseController is CustomProductPurchaseController - - /** - * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases: - * a dedicated [CustomProductPurchaseController], or any fully external controller (which may - * override purchase(customProduct:)). - */ - val hasCustomProductPurchaseController: Boolean - get() = kotlinPurchaseController is CustomProductPurchaseController || hasExternalPurchaseController + 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() - } - - override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { - if (kotlinPurchaseController != null) { - return kotlinPurchaseController.purchase(customProduct) } - // No PurchaseControllerJava overload for custom products yet — callers should - // be guarded by TransactionManager against this path when no external controller - // is configured. - return PurchaseResult.Failed("No PurchaseController configured to handle custom product purchase.") + // 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/abstractions/product/ApiStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt index 2bd51b1ca..3a47afafb 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -18,6 +19,10 @@ import java.util.Locale 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 { 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 b451bbf94..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.* @@ -78,27 +79,33 @@ sealed class OfferType { class StoreProduct private constructor( val rawStoreProduct: RawStoreProduct?, private val backingProduct: StoreProductType, - val isCustomProduct: Boolean = false, ) : 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(customProduct:)`. Regenerated on + * 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 flagged as a custom product, backed by an ApiStoreProduct. */ + /** Builds a StoreProduct backed by an ApiStoreProduct (custom or test-mode product). */ fun custom(apiStoreProduct: ApiStoreProduct): StoreProduct = StoreProduct( rawStoreProduct = null, backingProduct = apiStoreProduct, - isCustomProduct = true, ) } } 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 5393faa25..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 @@ -374,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") @@ -383,7 +382,7 @@ class TransactionManager( val result = storeManager.purchaseController.purchase( activity = activity, - productDetails = productDetails, + product = product, offerId = rawStoreProduct.offerId, basePlanId = rawStoreProduct.basePlanId, ) @@ -452,19 +451,20 @@ class TransactionManager( * 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(customProduct:)], and constructs a - * StoreTransaction without touching Google Play Billing / receipts. + * [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.makeHasCustomProductPurchaseController()) { + if (!factory.makeHasExternalPurchaseController()) { val message = - "Custom products require a PurchaseController that handles them. Configure " + - "Superwall with a CustomProductPurchaseController (or a PurchaseController " + - "that overrides purchase(customProduct:)) to handle ${product.fullIdentifier}." + "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) { @@ -523,7 +523,16 @@ class TransactionManager( ) } - val result = storeManager.purchaseController.purchase(customProduct = product) + 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. 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 index f124bd5d7..ed6f36f1a 100644 --- a/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt @@ -66,6 +66,91 @@ class CustomStoreProductTest { } } + @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") { 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 64da611e0..9c9388bc1 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt @@ -105,35 +105,17 @@ class InternalPurchaseControllerTest { } @Test - fun `test CustomProductPurchaseController is treated as internal, not external`() = + fun `test unified purchase delegates to Kotlin controller`() = runTest { - Given("an InternalPurchaseController wrapping a CustomProductPurchaseController") { - val customProductController = mockk() - val controller = - InternalPurchaseController( - kotlinPurchaseController = customProductController, - javaPurchaseController = null, - context = mockContext, - ) - - When("checking the controller flags") { - Then("it is internal — Superwall manages the standard Play lifecycle") { - assertTrue(controller.hasInternalPurchaseController) - assertFalse(controller.hasExternalPurchaseController) - } + Given("an InternalPurchaseController with an external Kotlin controller") { + val externalController = mockk() + val product = mockk() + val expectedResult = PurchaseResult.Purchased() - And("it can still fulfill custom product purchases") { - assertTrue(controller.hasCustomProductPurchaseController) - } - } - } - } + coEvery { + externalController.purchase(mockActivity, product, "base_plan", "offer") + } returns expectedResult - @Test - fun `test a fully external controller can fulfill custom products`() = - runTest { - Given("an InternalPurchaseController with a generic external controller") { - val externalController = mockk() val controller = InternalPurchaseController( kotlinPurchaseController = externalController, @@ -141,30 +123,37 @@ class InternalPurchaseControllerTest { context = mockContext, ) - When("checking hasCustomProductPurchaseController") { - Then("it is true — the controller may override purchase(customProduct:)") { - assertTrue(controller.hasExternalPurchaseController) - assertTrue(controller.hasCustomProductPurchaseController) + 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 automatic controller cannot fulfill custom products`() = + fun `test unified purchase of a custom product fails without a Kotlin controller`() = runTest { - Given("an InternalPurchaseController with an AutomaticPurchaseController") { - val automaticController = mockk() + Given("an InternalPurchaseController with no Kotlin controller") { + val product = mockk() + every { product.rawStoreProduct } returns null val controller = InternalPurchaseController( - kotlinPurchaseController = automaticController, + kotlinPurchaseController = null, javaPurchaseController = null, context = mockContext, ) - When("checking hasCustomProductPurchaseController") { - Then("it is false — custom purchases are rejected") { - assertFalse(controller.hasCustomProductPurchaseController) + 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) } } } 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 From bdcdfa689f41e27618868061a104e56659621b08 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 18:53:52 +0200 Subject: [PATCH 07/21] Expand 2.8.0 changelog entry Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d68121f0..9419bcc74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,12 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw ## 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. ## Breaking Changes - 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. From 2bb7304583a3de84c554807307a9e5568c7e8349 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 18:59:12 +0200 Subject: [PATCH 08/21] Raise minSdk to 23 for Billing 9 and add changelog warning Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ app/build.gradle.kts | 2 +- example/app/build.gradle.kts | 2 +- superwall-compose/build.gradle.kts | 2 +- superwall/build.gradle.kts | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9419bcc74..6e56d3b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - **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. + ## 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/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/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 { From 1e929274e21096b0073de65066453f5e1d5aa448 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 27 Jul 2026 17:03:40 +0000 Subject: [PATCH 09/21] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index f1db12a4c..95275ca45 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches35.3% +branches36.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index d0c80d131..ec50def2a 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage44.8% +coverage45.4% \ No newline at end of file From 5f859742e9a41ec0da1102c56cf88ee9b8e29d30 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 19:11:28 +0200 Subject: [PATCH 10/21] Bump changelog --- CHANGELOG.md | 13 +++++++++++++ version.env | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7f5434e..6f58e9a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 2.8.0 + +## Enhancements + +- 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. +- Renames `TestStoreProduct` to `ApiStoreProduct`, now shared between test mode and custom store products. + +## 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/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 From a5d3173218f900f8e34b47749645406edc756e54 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 19:47:30 +0200 Subject: [PATCH 11/21] Update tests --- .../analytics/internal/TrackingLogicTest.kt | 6 +++++ .../transactions/TransactionManagerTest.kt | 24 +++++++++---------- 2 files changed, 18 insertions(+), 12 deletions(-) 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(), ) From d342606e53790831694db2919f629c672fe2389d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:10:44 +0000 Subject: [PATCH 12/21] feat: forward re-routed back presses into the paywall as back_button_input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reroute_back_button is enabled in Paywall settings, a system back press previously did nothing unless the host app set the PaywallOptions.onBackPressed callback — otherwise the paywall dismissed as if the setting were off. Back presses the app callback doesn't consume are now injected into the webview as the paywall-js back_button_input message. The paywall either navigates its flow back one page or posts the existing close message, which dismisses through the standard manual-close path (Declined / ManualClose), so back-at-root behaves exactly like a native dismissal. The press/consume decision is extracted into backPressBehavior() and unit tested; the JS bridge payload is covered in PaywallViewTest. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 3 + .../sdk/config/options/PaywallOptions.kt | 4 +- .../sdk/paywall/view/BackPressBehavior.kt | 33 +++++++++ .../superwall/sdk/paywall/view/PaywallView.kt | 30 +++++++- .../paywall/view/SuperwallPaywallActivity.kt | 30 ++++---- .../webview/messaging/BackButtonInputEvent.kt | 19 +++++ .../sdk/paywall/view/BackPressBehaviorTest.kt | 70 +++++++++++++++++++ .../sdk/paywall/view/PaywallViewTest.kt | 25 +++++++ 8 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7acaf69..af1055d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 +## Enhancements +- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: the paywall navigates its flow back one page when possible, and otherwise closes itself through the standard manual-close path. The `PaywallOptions.onBackPressed` app callback keeps first refusal; forwarding happens only when it is unset or returns `false`. + ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. - Ensure that errors thrown when user lacks billing do not propagate to the app 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/paywall/view/BackPressBehavior.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt new file mode 100644 index 000000000..b9efc6954 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -0,0 +1,33 @@ +package com.superwall.sdk.paywall.view + +import com.superwall.sdk.models.paywall.Paywall + +internal enum class BackPressBehavior { + /** The host app's `onBackPressed` callback consumed the press. */ + CONSUMED_BY_APP, + + /** Forward the press into the paywall as a `back_button_input` message. */ + FORWARD_TO_PAYWALL, + + /** Dismiss the paywall, matching a manual close. */ + DISMISS, +} + +/** + * Decides what a system back press does while a paywall is presented. + * + * With `reroute_back_button` enabled in Paywall settings, the host app's + * `PaywallOptions.onBackPressed` callback gets first refusal; if it doesn't + * consume the press, the press is forwarded into the paywall, which either + * navigates its flow back one page or posts `close` to dismiss. With the + * setting disabled the paywall dismisses immediately. + */ +internal fun backPressBehavior( + rerouteBackButton: Paywall.ToggleMode?, + consumedByApp: () -> Boolean, +): BackPressBehavior = + when { + rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS + consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP + else -> BackPressBehavior.FORWARD_TO_PAYWALL + } 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..e0f8c1aa3 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 @@ -67,6 +67,7 @@ import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SWWebView import com.superwall.sdk.paywall.view.webview.SendPaywallMessages import com.superwall.sdk.paywall.view.webview.WebviewError +import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate import com.superwall.sdk.paywall.view.webview.messaging.PaywallStateDelegate @@ -139,7 +140,7 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() - private val gameControllerJson by lazy { + private val webEventJson by lazy { Json { encodeDefaults = true namingStrategy = JsonNamingStrategy.SnakeCase @@ -1091,7 +1092,7 @@ class PaywallView( override fun gameControllerEventOccured(event: GameControllerEvent) { val payload = try { - gameControllerJson.encodeToString(event) + webEventJson.encodeToString(event) } catch (e: Throwable) { null } @@ -1105,6 +1106,31 @@ 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. Called when + * `reroute_back_button` is enabled in Paywall settings. + */ + fun backButtonPressed() { + val payload = + try { + webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + } catch (e: Throwable) { + null + } ?: return + webView.evaluate("window.paywall.accept([$payload])", null) + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = "Back button press forwarded to paywall: $payload", + ) + } + +//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..6f4911f36 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,19 +296,24 @@ 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( - result = PaywallResult.Declined(), - closeReason = PaywallCloseReason.ManualClose, + val paywallView = paywallView() + val behavior = + backPressBehavior( + rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, + consumedByApp = { + Superwall.instance.options.paywalls.onBackPressed + ?.let { it(paywallView?.info) } + ?: false + }, ) + when (behavior) { + BackPressBehavior.CONSUMED_BY_APP -> Unit + BackPressBehavior.FORWARD_TO_PAYWALL -> paywallView?.backButtonPressed() + BackPressBehavior.DISMISS -> + view.dismiss( + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ManualClose, + ) } } }, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt new file mode 100644 index 000000000..7aa20a737 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt @@ -0,0 +1,19 @@ +package com.superwall.sdk.paywall.view.webview.messaging + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Message injected into the paywall webview when the system back button is + * re-routed to the paywall (`reroute_back_button` enabled in Paywall settings). + * + * The paywall responds by either navigating its flow back one page or posting + * the `close` message, which dismisses via the standard manual-close path. + */ +@Serializable +data class BackButtonInputEvent( + @SerialName("event_name") + val eventName: String = "back_button_input", + @SerialName("pressed") + val pressed: Boolean, +) 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..0999c71d6 --- /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.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class BackPressBehaviorTest { + @Test + fun dismisses_when_reroute_is_disabled() { + Given("a paywall with reroute_back_button disabled") { + var callbackInvoked = false + + When("the back button is pressed") { + val behavior = + backPressBehavior(Paywall.ToggleMode.DISABLED) { + callbackInvoked = true + true + } + + Then("the paywall dismisses without consulting the app callback") { + assertEquals(BackPressBehavior.DISMISS, behavior) + assertFalse(callbackInvoked) + } + } + } + } + + @Test + fun dismisses_when_reroute_is_unset() { + Given("a paywall without a reroute_back_button setting") { + When("the back button is pressed") { + val behavior = backPressBehavior(null) { true } + + Then("the paywall dismisses") { + assertEquals(BackPressBehavior.DISMISS, behavior) + } + } + } + } + + @Test + fun app_callback_gets_first_refusal_when_reroute_is_enabled() { + Given("a paywall with reroute_back_button enabled and a consuming app callback") { + When("the back button is pressed") { + val behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } + + Then("the press is consumed by the app") { + assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) + } + } + } + } + + @Test + fun forwards_to_paywall_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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } + + Then("the press is forwarded into the paywall") { + assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) + } + } + } + } +} 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..78029a03f 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 @@ -21,6 +21,7 @@ import com.superwall.sdk.paywall.view.delegate.PaywallViewEventCallback import com.superwall.sdk.paywall.view.webview.PaywallUIDelegate import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SendPaywallMessages +import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent @@ -237,6 +238,30 @@ class PaywallViewTest { } } + @Test + fun backButtonPressed_evaluatesJavascriptBridge() { + Given("a paywall that re-routes the system back button") { + When("a back press is forwarded to the paywall") { + paywallView.backButtonPressed() + + Then("the WebUI receives a back_button_input press") { + val expectedPayload = + Json { + encodeDefaults = true + namingStrategy = JsonNamingStrategy.SnakeCase + }.encodeToString( + BackButtonInputEvent.serializer(), + BackButtonInputEvent(pressed = true), + ) + assertEquals( + listOf("window.paywall.accept([$expectedPayload])"), + fakeWebUI.evaluateCalls, + ) + } + } + } + } + @Test fun destroyWebview_forwardsCall() { Given("a PaywallView") { From cec47f3807c807c3ce1083b8aed821331eb8ae96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:29:26 +0000 Subject: [PATCH 13/21] feat: forward back presses to the paywall by default, gated by capability probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-page flow paywalls were closing entirely on system back instead of navigating back a page. Forwarding is now the default for every paywall, not just those with reroute_back_button enabled — that setting now only controls whether the PaywallOptions.onBackPressed app callback gets first refusal. Because published paywalls pin their built runtime, blindly forwarding would strand users on paywalls whose runtime lacks the back_button_input consumer. The SDK therefore probes the webview for window.paywall.supportsBackButtonInput first; when absent (older runtime, still loading, crashed webview) it falls back to the native dismiss, preserving today's behavior exactly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 2 +- .../sdk/config/options/PaywallOptions.kt | 3 +- .../sdk/paywall/view/BackPressBehavior.kt | 34 ++++--------- .../superwall/sdk/paywall/view/PaywallView.kt | 49 ++++++++++++------- .../paywall/view/SuperwallPaywallActivity.kt | 21 ++++---- .../sdk/paywall/view/BackPressBehaviorTest.kt | 36 +++++++------- .../sdk/paywall/view/PaywallViewTest.kt | 45 ++++++++++++++--- 7 files changed, 112 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af1055d19..764d695d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 ## Enhancements -- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: the paywall navigates its flow back one page when possible, and otherwise closes itself through the standard manual-close path. The `PaywallOptions.onBackPressed` app callback keeps first refusal; forwarding happens only when it is unset or returns `false`. +- System back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`), so single-page paywalls dismiss exactly as before. The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. When `reroute_back_button` is enabled in Paywall settings, the `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. 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 3ceca48ab..68d130998 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 @@ -162,7 +162,8 @@ class PaywallOptions() { * You can use this to react to back button presses with custom 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. + * and the paywall either navigates its flow back one page or closes itself. If the + * paywall's runtime can't handle the message, the paywall dismisses natively. */ var onBackPressed: ((PaywallInfo?) -> Boolean)? = null } 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 index b9efc6954..28aa2ed6b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -2,32 +2,18 @@ package com.superwall.sdk.paywall.view import com.superwall.sdk.models.paywall.Paywall -internal enum class BackPressBehavior { - /** The host app's `onBackPressed` callback consumed the press. */ - CONSUMED_BY_APP, - - /** Forward the press into the paywall as a `back_button_input` message. */ - FORWARD_TO_PAYWALL, - - /** Dismiss the paywall, matching a manual close. */ - DISMISS, -} - /** - * Decides what a system back press does while a paywall is presented. + * 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. * - * With `reroute_back_button` enabled in Paywall settings, the host app's - * `PaywallOptions.onBackPressed` callback gets first refusal; if it doesn't - * consume the press, the press is forwarded into the paywall, which either - * navigates its flow back one page or posts `close` to dismiss. With the - * setting disabled the paywall dismisses immediately. + * 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. Paywalls that can't + * handle the message (older runtimes, loading/crashed webviews) fall back to + * the SDK's native dismiss — see `PaywallView.backButtonPressed`. */ -internal fun backPressBehavior( +internal fun isBackPressConsumedByApp( rerouteBackButton: Paywall.ToggleMode?, consumedByApp: () -> Boolean, -): BackPressBehavior = - when { - rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS - consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP - else -> BackPressBehavior.FORWARD_TO_PAYWALL - } +): 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 e0f8c1aa3..41d5f0299 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 @@ -140,6 +140,8 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() + private const val SUPPORTS_BACK_BUTTON_INPUT_PROBE = + "typeof window.paywall !== 'undefined' && window.paywall.supportsBackButtonInput === true" private val webEventJson by lazy { Json { encodeDefaults = true @@ -1109,24 +1111,37 @@ class PaywallView( //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. Called when - * `reroute_back_button` is enabled in Paywall settings. + * Handles a system back press. Probes the webview for the paywall-js + * `supportsBackButtonInput` capability: when present, the press is + * forwarded into the paywall as a `back_button_input` message and the + * paywall either navigates its flow back one page or posts `close`, + * which dismisses via the standard manual-close path. When the paywall + * can't handle it (older runtime, still loading, crashed webview), + * [onUnhandled] is invoked so the caller dismisses natively instead. */ - fun backButtonPressed() { - val payload = - try { - webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) - } catch (e: Throwable) { - null - } ?: return - webView.evaluate("window.paywall.accept([$payload])", null) - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallView, - message = "Back button press forwarded to paywall: $payload", - ) + fun backButtonPressed(onUnhandled: () -> Unit) { + webView.evaluate(SUPPORTS_BACK_BUTTON_INPUT_PROBE) { result -> + if (result?.trim() != "true") { + onUnhandled() + return@evaluate + } + val payload = + try { + webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + } catch (e: Throwable) { + null + } + if (payload == null) { + onUnhandled() + return@evaluate + } + webView.evaluate("window.paywall.accept([$payload])", null) + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = "Back button press forwarded to paywall: $payload", + ) + } } //endregion 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 6f4911f36..adf5edb70 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 @@ -297,8 +297,8 @@ class SuperwallPaywallActivity : AppCompatActivity() { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val paywallView = paywallView() - val behavior = - backPressBehavior( + val consumedByApp = + isBackPressConsumedByApp( rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, consumedByApp = { Superwall.instance.options.paywalls.onBackPressed @@ -306,15 +306,16 @@ class SuperwallPaywallActivity : AppCompatActivity() { ?: false }, ) - when (behavior) { - BackPressBehavior.CONSUMED_BY_APP -> Unit - BackPressBehavior.FORWARD_TO_PAYWALL -> paywallView?.backButtonPressed() - BackPressBehavior.DISMISS -> - view.dismiss( - result = PaywallResult.Declined(), - closeReason = PaywallCloseReason.ManualClose, - ) + if (consumedByApp) { + return + } + val dismiss = { + view.dismiss( + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ManualClose, + ) } + paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() } }, ) 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 index 0999c71d6..5f06e5284 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt @@ -4,25 +4,25 @@ 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.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class BackPressBehaviorTest { @Test - fun dismisses_when_reroute_is_disabled() { - Given("a paywall with reroute_back_button disabled") { + 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 behavior = - backPressBehavior(Paywall.ToggleMode.DISABLED) { + val consumed = + isBackPressConsumedByApp(Paywall.ToggleMode.DISABLED) { callbackInvoked = true true } - Then("the paywall dismisses without consulting the app callback") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed and the callback is never invoked") { + assertFalse(consumed) assertFalse(callbackInvoked) } } @@ -30,39 +30,39 @@ class BackPressBehaviorTest { } @Test - fun dismisses_when_reroute_is_unset() { + 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 behavior = backPressBehavior(null) { true } + val consumed = isBackPressConsumedByApp(null) { true } - Then("the paywall dismisses") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed") { + assertFalse(consumed) } } } } @Test - fun app_callback_gets_first_refusal_when_reroute_is_enabled() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { true } Then("the press is consumed by the app") { - assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) + assertTrue(consumed) } } } } @Test - fun forwards_to_paywall_when_reroute_is_enabled_and_app_declines() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { false } - Then("the press is forwarded into the paywall") { - assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) + 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/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt index 78029a03f..42e656a0b 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 @@ -239,12 +239,15 @@ class PaywallViewTest { } @Test - fun backButtonPressed_evaluatesJavascriptBridge() { - Given("a paywall that re-routes the system back button") { - When("a back press is forwarded to the paywall") { - paywallView.backButtonPressed() + fun backButtonPressed_forwardsToPaywallWhenSupported() { + Given("a paywall whose runtime supports back_button_input") { + fakeWebUI.evaluateResult = "true" + var unhandled = false - Then("the WebUI receives a back_button_input press") { + When("a back press occurs") { + paywallView.backButtonPressed(onUnhandled = { unhandled = true }) + + Then("the WebUI is probed then receives a back_button_input press") { val expectedPayload = Json { encodeDefaults = true @@ -254,9 +257,36 @@ class PaywallViewTest { BackButtonInputEvent(pressed = true), ) assertEquals( - listOf("window.paywall.accept([$expectedPayload])"), + listOf( + "typeof window.paywall !== 'undefined' && " + + "window.paywall.supportsBackButtonInput === true", + "window.paywall.accept([$expectedPayload])", + ), + fakeWebUI.evaluateCalls, + ) + assertFalse(unhandled) + } + } + } + } + + @Test + fun backButtonPressed_fallsBackWhenRuntimeLacksSupport() { + Given("a paywall whose runtime does not support back_button_input") { + var unhandled = false + + When("a back press occurs") { + paywallView.backButtonPressed(onUnhandled = { unhandled = true }) + + Then("nothing is injected and the press is reported as unhandled") { + assertEquals( + listOf( + "typeof window.paywall !== 'undefined' && " + + "window.paywall.supportsBackButtonInput === true", + ), fakeWebUI.evaluateCalls, ) + assertTrue(unhandled) } } } @@ -534,6 +564,7 @@ class PaywallViewTest { var destroyed: Boolean = false var setupLatch: CountDownLatch? = null val evaluateCalls = mutableListOf() + var evaluateResult: String? = null private val view = View(context) override fun onView(perform: View.() -> Unit) { @@ -570,7 +601,7 @@ class PaywallViewTest { resultCallback: ((String?) -> Unit)?, ) { evaluateCalls += code - resultCallback?.invoke(null) + resultCallback?.invoke(evaluateResult) } override fun destroyView() { From d7a538c87a2418b5b166cdde6ff41055a34ff0e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 12:55:18 +0000 Subject: [PATCH 14/21] revert to opt-in: only forward back presses when reroute_back_button is ENABLED Forwarding by default changes implied behavior for existing multi-page paywalls, so gate it behind the existing per-paywall dashboard setting while the default story is decided (possibly a new setting). DISABLED and unset paywalls dismiss on back exactly as before. The capability probe stays: even ENABLED paywalls on older runtimes keep the native dismiss. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 2 +- .../sdk/config/options/PaywallOptions.kt | 1 + .../sdk/paywall/view/BackPressBehavior.kt | 39 ++++++++++++++----- .../paywall/view/SuperwallPaywallActivity.kt | 14 ++++--- .../sdk/paywall/view/BackPressBehaviorTest.kt | 34 ++++++++-------- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 764d695d4..5083e19dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 ## Enhancements -- System back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`), so single-page paywalls dismiss exactly as before. The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. When `reroute_back_button` is enabled in Paywall settings, the `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. +- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`). The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. The `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. Paywalls with the setting disabled (the default) are unaffected and dismiss on back exactly as before. ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. 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 68d130998..88e12e1ff 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 @@ -164,6 +164,7 @@ class PaywallOptions() { * 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. If the * paywall's runtime can't handle the message, the paywall dismisses natively. + * Paywalls with `reroute_back_button` disabled dismiss on back press directly. */ var onBackPressed: ((PaywallInfo?) -> Boolean)? = null } 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 index 28aa2ed6b..512a235ab 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -2,18 +2,37 @@ package com.superwall.sdk.paywall.view import com.superwall.sdk.models.paywall.Paywall +internal enum class BackPressBehavior { + /** The host app's `onBackPressed` callback consumed the press. */ + CONSUMED_BY_APP, + + /** Forward the press into the paywall as a `back_button_input` message. */ + FORWARD_TO_PAYWALL, + + /** Dismiss the paywall, matching a manual close. */ + DISMISS, +} + /** - * 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. + * Decides what a system back press does while a paywall is presented. + * + * With `reroute_back_button` enabled in Paywall settings, the host app's + * `PaywallOptions.onBackPressed` callback gets first refusal; a press it + * doesn't consume is forwarded into the paywall as a `back_button_input` + * message, and the paywall either navigates its flow back one page or posts + * `close` to dismiss. Paywalls that can't handle the message (older + * runtimes, loading/crashed webviews) fall back to the SDK's native dismiss + * — see `PaywallView.backButtonPressed`. * - * 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. Paywalls that can't - * handle the message (older runtimes, loading/crashed webviews) fall back to - * the SDK's native dismiss — see `PaywallView.backButtonPressed`. + * With the setting disabled (the default) the paywall dismisses + * immediately, unchanged from previous SDK versions. */ -internal fun isBackPressConsumedByApp( +internal fun backPressBehavior( rerouteBackButton: Paywall.ToggleMode?, consumedByApp: () -> Boolean, -): Boolean = rerouteBackButton == Paywall.ToggleMode.ENABLED && consumedByApp() +): BackPressBehavior = + when { + rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS + consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP + else -> BackPressBehavior.FORWARD_TO_PAYWALL + } 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 adf5edb70..4e1a6e110 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 @@ -297,8 +297,8 @@ class SuperwallPaywallActivity : AppCompatActivity() { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val paywallView = paywallView() - val consumedByApp = - isBackPressConsumedByApp( + val behavior = + backPressBehavior( rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, consumedByApp = { Superwall.instance.options.paywalls.onBackPressed @@ -306,16 +306,18 @@ class SuperwallPaywallActivity : AppCompatActivity() { ?: false }, ) - if (consumedByApp) { - return - } val dismiss = { view.dismiss( result = PaywallResult.Declined(), closeReason = PaywallCloseReason.ManualClose, ) } - paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() + when (behavior) { + BackPressBehavior.CONSUMED_BY_APP -> Unit + BackPressBehavior.FORWARD_TO_PAYWALL -> + paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() + BackPressBehavior.DISMISS -> dismiss() + } } }, ) 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 index 5f06e5284..0b93fe77a 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt @@ -4,25 +4,25 @@ 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.assertEquals 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() { + fun dismisses_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) { + val behavior = + backPressBehavior(Paywall.ToggleMode.DISABLED) { callbackInvoked = true true } - Then("the press is not consumed and the callback is never invoked") { - assertFalse(consumed) + Then("the paywall dismisses without consulting the app callback") { + assertEquals(BackPressBehavior.DISMISS, behavior) assertFalse(callbackInvoked) } } @@ -30,39 +30,39 @@ class BackPressBehaviorTest { } @Test - fun app_callback_is_not_consulted_when_reroute_is_unset() { + fun dismisses_when_reroute_is_unset() { Given("a paywall without a reroute_back_button setting") { When("the back button is pressed") { - val consumed = isBackPressConsumedByApp(null) { true } + val behavior = backPressBehavior(null) { true } - Then("the press is not consumed") { - assertFalse(consumed) + Then("the paywall dismisses") { + assertEquals(BackPressBehavior.DISMISS, behavior) } } } } @Test - fun app_callback_consumes_the_press_when_reroute_is_enabled() { + fun app_callback_gets_first_refusal_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 } + val behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } Then("the press is consumed by the app") { - assertTrue(consumed) + assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) } } } } @Test - fun press_is_not_consumed_when_reroute_is_enabled_and_app_declines() { + fun forwards_to_paywall_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 } + val behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } - Then("the press is not consumed and falls through to the paywall") { - assertFalse(consumed) + Then("the press is forwarded into the paywall") { + assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) } } } From d8a2c31c3af2d3597bc087c48bff77a79a36876f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 11:39:22 +0000 Subject: [PATCH 15/21] forward back presses unconditionally; drop capability probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team decision: this ships as an accepted breaking change. Every system back press the app callback doesn't consume is forwarded into the paywall as back_button_input — the paywall navigates its flow back one page or posts close (Declined/ManualClose) at the root. The reroute_back_button setting now only controls whether the PaywallOptions.onBackPressed callback is consulted first. Native dismissal remains only when no PaywallView is attached to the activity. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 4 +- .../sdk/config/options/PaywallOptions.kt | 4 +- .../sdk/paywall/view/BackPressBehavior.kt | 38 ++++----------- .../superwall/sdk/paywall/view/PaywallView.kt | 48 +++++++------------ .../paywall/view/SuperwallPaywallActivity.kt | 20 ++++---- .../sdk/paywall/view/BackPressBehaviorTest.kt | 34 ++++++------- .../sdk/paywall/view/PaywallViewTest.kt | 45 +++-------------- 7 files changed, 61 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5083e19dc..90e448f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,8 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 -## Enhancements -- When `reroute_back_button` is enabled in Paywall settings, system back presses are now forwarded into the paywall as a `back_button_input` message: multi-page flows navigate back one page instead of dismissing the whole paywall, and paywalls with nowhere to go back close themselves through the standard manual-close path (`Declined`/`ManualClose`). The SDK first probes the webview for `window.paywall.supportsBackButtonInput`; paywalls built on older runtimes (or still loading / crashed) keep the previous native dismiss. The `PaywallOptions.onBackPressed` app callback keeps first refusal before the press is forwarded. Paywalls with the setting disabled (the default) are unaffected and dismiss on back exactly as before. +## 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. ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. 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 88e12e1ff..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 @@ -162,9 +162,7 @@ class PaywallOptions() { * You can use this to react to back button presses with custom 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. If the - * paywall's runtime can't handle the message, the paywall dismisses natively. - * Paywalls with `reroute_back_button` disabled dismiss on back press directly. + * 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/paywall/view/BackPressBehavior.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt index 512a235ab..e2124bb2e 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/BackPressBehavior.kt @@ -2,37 +2,17 @@ package com.superwall.sdk.paywall.view import com.superwall.sdk.models.paywall.Paywall -internal enum class BackPressBehavior { - /** The host app's `onBackPressed` callback consumed the press. */ - CONSUMED_BY_APP, - - /** Forward the press into the paywall as a `back_button_input` message. */ - FORWARD_TO_PAYWALL, - - /** Dismiss the paywall, matching a manual close. */ - DISMISS, -} - /** - * Decides what a system back press does while a paywall is presented. - * - * With `reroute_back_button` enabled in Paywall settings, the host app's - * `PaywallOptions.onBackPressed` callback gets first refusal; a press it - * doesn't consume is forwarded into the paywall as a `back_button_input` - * message, and the paywall either navigates its flow back one page or posts - * `close` to dismiss. Paywalls that can't handle the message (older - * runtimes, loading/crashed webviews) fall back to the SDK's native dismiss - * — see `PaywallView.backButtonPressed`. + * 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. * - * With the setting disabled (the default) the paywall dismisses - * immediately, unchanged from previous SDK versions. + * 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 backPressBehavior( +internal fun isBackPressConsumedByApp( rerouteBackButton: Paywall.ToggleMode?, consumedByApp: () -> Boolean, -): BackPressBehavior = - when { - rerouteBackButton != Paywall.ToggleMode.ENABLED -> BackPressBehavior.DISMISS - consumedByApp() -> BackPressBehavior.CONSUMED_BY_APP - else -> BackPressBehavior.FORWARD_TO_PAYWALL - } +): 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 41d5f0299..c38fcaba8 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 @@ -140,8 +140,6 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() - private const val SUPPORTS_BACK_BUTTON_INPUT_PROBE = - "typeof window.paywall !== 'undefined' && window.paywall.supportsBackButtonInput === true" private val webEventJson by lazy { Json { encodeDefaults = true @@ -1111,37 +1109,23 @@ class PaywallView( //region Back button /** - * Handles a system back press. Probes the webview for the paywall-js - * `supportsBackButtonInput` capability: when present, the press is - * forwarded into the paywall as a `back_button_input` message and the - * paywall either navigates its flow back one page or posts `close`, - * which dismisses via the standard manual-close path. When the paywall - * can't handle it (older runtime, still loading, crashed webview), - * [onUnhandled] is invoked so the caller dismisses natively instead. + * 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(onUnhandled: () -> Unit) { - webView.evaluate(SUPPORTS_BACK_BUTTON_INPUT_PROBE) { result -> - if (result?.trim() != "true") { - onUnhandled() - return@evaluate - } - val payload = - try { - webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) - } catch (e: Throwable) { - null - } - if (payload == null) { - onUnhandled() - return@evaluate - } - webView.evaluate("window.paywall.accept([$payload])", null) - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallView, - message = "Back button press forwarded to paywall: $payload", - ) - } + fun backButtonPressed() { + val payload = + try { + webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + } catch (e: Throwable) { + null + } ?: return + webView.evaluate("window.paywall.accept([$payload])", null) + Logger.debug( + logLevel = LogLevel.debug, + scope = LogScope.paywallView, + message = "Back button press forwarded to paywall: $payload", + ) } //endregion 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 4e1a6e110..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 @@ -297,8 +297,8 @@ class SuperwallPaywallActivity : AppCompatActivity() { object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val paywallView = paywallView() - val behavior = - backPressBehavior( + val consumedByApp = + isBackPressConsumedByApp( rerouteBackButton = paywallView?.state?.paywall?.rerouteBackButton, consumedByApp = { Superwall.instance.options.paywalls.onBackPressed @@ -306,18 +306,16 @@ class SuperwallPaywallActivity : AppCompatActivity() { ?: false }, ) - val dismiss = { - view.dismiss( + 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, ) - } - when (behavior) { - BackPressBehavior.CONSUMED_BY_APP -> Unit - BackPressBehavior.FORWARD_TO_PAYWALL -> - paywallView?.backButtonPressed(onUnhandled = dismiss) ?: dismiss() - BackPressBehavior.DISMISS -> dismiss() - } } }, ) 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 index 0b93fe77a..5f06e5284 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/view/BackPressBehaviorTest.kt @@ -4,25 +4,25 @@ 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.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class BackPressBehaviorTest { @Test - fun dismisses_when_reroute_is_disabled() { + 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 behavior = - backPressBehavior(Paywall.ToggleMode.DISABLED) { + val consumed = + isBackPressConsumedByApp(Paywall.ToggleMode.DISABLED) { callbackInvoked = true true } - Then("the paywall dismisses without consulting the app callback") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed and the callback is never invoked") { + assertFalse(consumed) assertFalse(callbackInvoked) } } @@ -30,39 +30,39 @@ class BackPressBehaviorTest { } @Test - fun dismisses_when_reroute_is_unset() { + 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 behavior = backPressBehavior(null) { true } + val consumed = isBackPressConsumedByApp(null) { true } - Then("the paywall dismisses") { - assertEquals(BackPressBehavior.DISMISS, behavior) + Then("the press is not consumed") { + assertFalse(consumed) } } } } @Test - fun app_callback_gets_first_refusal_when_reroute_is_enabled() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { true } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { true } Then("the press is consumed by the app") { - assertEquals(BackPressBehavior.CONSUMED_BY_APP, behavior) + assertTrue(consumed) } } } } @Test - fun forwards_to_paywall_when_reroute_is_enabled_and_app_declines() { + 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 behavior = backPressBehavior(Paywall.ToggleMode.ENABLED) { false } + val consumed = isBackPressConsumedByApp(Paywall.ToggleMode.ENABLED) { false } - Then("the press is forwarded into the paywall") { - assertEquals(BackPressBehavior.FORWARD_TO_PAYWALL, behavior) + 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/PaywallViewTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/view/PaywallViewTest.kt index 42e656a0b..d98135761 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 @@ -239,15 +239,12 @@ class PaywallViewTest { } @Test - fun backButtonPressed_forwardsToPaywallWhenSupported() { - Given("a paywall whose runtime supports back_button_input") { - fakeWebUI.evaluateResult = "true" - var unhandled = false + fun backButtonPressed_evaluatesJavascriptBridge() { + Given("a paywall presented with the system back button pressed") { + When("the press is forwarded to the paywall") { + paywallView.backButtonPressed() - When("a back press occurs") { - paywallView.backButtonPressed(onUnhandled = { unhandled = true }) - - Then("the WebUI is probed then receives a back_button_input press") { + Then("the WebUI receives a back_button_input press") { val expectedPayload = Json { encodeDefaults = true @@ -257,36 +254,9 @@ class PaywallViewTest { BackButtonInputEvent(pressed = true), ) assertEquals( - listOf( - "typeof window.paywall !== 'undefined' && " + - "window.paywall.supportsBackButtonInput === true", - "window.paywall.accept([$expectedPayload])", - ), - fakeWebUI.evaluateCalls, - ) - assertFalse(unhandled) - } - } - } - } - - @Test - fun backButtonPressed_fallsBackWhenRuntimeLacksSupport() { - Given("a paywall whose runtime does not support back_button_input") { - var unhandled = false - - When("a back press occurs") { - paywallView.backButtonPressed(onUnhandled = { unhandled = true }) - - Then("nothing is injected and the press is reported as unhandled") { - assertEquals( - listOf( - "typeof window.paywall !== 'undefined' && " + - "window.paywall.supportsBackButtonInput === true", - ), + listOf("window.paywall.accept([$expectedPayload])"), fakeWebUI.evaluateCalls, ) - assertTrue(unhandled) } } } @@ -564,7 +534,6 @@ class PaywallViewTest { var destroyed: Boolean = false var setupLatch: CountDownLatch? = null val evaluateCalls = mutableListOf() - var evaluateResult: String? = null private val view = View(context) override fun onView(perform: View.() -> Unit) { @@ -601,7 +570,7 @@ class PaywallViewTest { resultCallback: ((String?) -> Unit)?, ) { evaluateCalls += code - resultCallback?.invoke(evaluateResult) + resultCallback?.invoke(null) } override fun destroyView() { From 2e4192f3f37dea1ec851e658311751cdd4012fba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:05:07 +0000 Subject: [PATCH 16/21] drop pressed field from BackButtonInputEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message's arrival is the signal — only presses are ever sent, so the boolean carried no information. Matches the simplified paywall-js schema. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- .../main/java/com/superwall/sdk/paywall/view/PaywallView.kt | 2 +- .../paywall/view/webview/messaging/BackButtonInputEvent.kt | 4 +--- .../java/com/superwall/sdk/paywall/view/PaywallViewTest.kt | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) 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 c38fcaba8..fa36a6ccc 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 @@ -1116,7 +1116,7 @@ class PaywallView( fun backButtonPressed() { val payload = try { - webEventJson.encodeToString(BackButtonInputEvent(pressed = true)) + webEventJson.encodeToString(BackButtonInputEvent()) } catch (e: Throwable) { null } ?: return diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt index 7aa20a737..4bc7deb79 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable /** * Message injected into the paywall webview when the system back button is - * re-routed to the paywall (`reroute_back_button` enabled in Paywall settings). + * pressed while a paywall is presented. * * The paywall responds by either navigating its flow back one page or posting * the `close` message, which dismisses via the standard manual-close path. @@ -14,6 +14,4 @@ import kotlinx.serialization.Serializable data class BackButtonInputEvent( @SerialName("event_name") val eventName: String = "back_button_input", - @SerialName("pressed") - val pressed: Boolean, ) 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 d98135761..550b8633b 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 @@ -244,14 +244,14 @@ class PaywallViewTest { When("the press is forwarded to the paywall") { paywallView.backButtonPressed() - Then("the WebUI receives a back_button_input press") { + Then("the WebUI receives a back_button_input message") { val expectedPayload = Json { encodeDefaults = true namingStrategy = JsonNamingStrategy.SnakeCase }.encodeToString( BackButtonInputEvent.serializer(), - BackButtonInputEvent(pressed = true), + BackButtonInputEvent(), ) assertEquals( listOf("window.paywall.accept([$expectedPayload])"), From 8cc6d5f89679adae8098f62d95319622539b5922 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:46:11 +0000 Subject: [PATCH 17/21] move back-button changelog entry into 2.8.0 after rebase The Unreleased section this entry was written under became 2.7.23 on develop; the change ships with 2.8.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e448f56..0a0ae00f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - Renames `TestStoreProduct` to `ApiStoreProduct`, now shared between test mode and 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`). @@ -29,9 +30,6 @@ Google Play Billing Library 9 requires Android 6.0 (API 23), so the SDK's `minSd ## 2.7.23 -## 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. - ## Fixes - Fix paywall inputs silently dropping all non-Latin keyboard text (Cyrillic, Korean, Thai, emoji, etc.). The webview no longer replaces Chromium's `InputConnection` with a dummy one unless `isGameControllerEnabled` is set, restoring the full IME pipeline (composition, autocorrect, swipe typing, autofill) for everyone else. - Ensure that errors thrown when user lacks billing do not propagate to the app From dcc8e352d61a96fc306dd2f0a2c1f67ff70f9e5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:51:01 +0000 Subject: [PATCH 18/21] route back press through PaywallMessageHandler Adds PaywallMessage.BackButtonPressed and handles it via the standard pass()/accept64 pipeline instead of a hand-rolled evaluate in PaywallView, reusing the established main-thread dispatch, error logging, and encoding. Removes BackButtonInputEvent and reverts the gameControllerJson rename it motivated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017vi3SWn6yXT5kTBj7NEb3m --- .../superwall/sdk/paywall/view/PaywallView.kt | 18 ++--------- .../webview/messaging/BackButtonInputEvent.kt | 17 ---------- .../view/webview/messaging/PaywallMessage.kt | 7 +++++ .../messaging/PaywallMessageHandler.kt | 5 +++ .../paywall/view/PaywallMessageHandlerTest.kt | 31 +++++++++++++++++++ .../sdk/paywall/view/PaywallViewTest.kt | 20 +++--------- 6 files changed, 51 insertions(+), 47 deletions(-) delete mode 100644 superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt 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 fa36a6ccc..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 @@ -67,7 +67,6 @@ import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SWWebView import com.superwall.sdk.paywall.view.webview.SendPaywallMessages import com.superwall.sdk.paywall.view.webview.WebviewError -import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandlerDelegate import com.superwall.sdk.paywall.view.webview.messaging.PaywallStateDelegate @@ -140,7 +139,7 @@ class PaywallView( private companion object { private val mainScope: MainScope = MainScope() private val ioScope: IOScope = IOScope() - private val webEventJson by lazy { + private val gameControllerJson by lazy { Json { encodeDefaults = true namingStrategy = JsonNamingStrategy.SnakeCase @@ -1092,7 +1091,7 @@ class PaywallView( override fun gameControllerEventOccured(event: GameControllerEvent) { val payload = try { - webEventJson.encodeToString(event) + gameControllerJson.encodeToString(event) } catch (e: Throwable) { null } @@ -1114,18 +1113,7 @@ class PaywallView( * which dismisses via the standard manual-close path. */ fun backButtonPressed() { - val payload = - try { - webEventJson.encodeToString(BackButtonInputEvent()) - } catch (e: Throwable) { - null - } ?: return - webView.evaluate("window.paywall.accept([$payload])", null) - Logger.debug( - logLevel = LogLevel.debug, - scope = LogScope.paywallView, - message = "Back button press forwarded to paywall: $payload", - ) + webView.messageHandler.handle(PaywallMessage.BackButtonPressed) } //endregion diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt deleted file mode 100644 index 4bc7deb79..000000000 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/BackButtonInputEvent.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.superwall.sdk.paywall.view.webview.messaging - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Message injected into the paywall webview when the system back button is - * pressed while a paywall is presented. - * - * The paywall responds by either navigating its flow back one page or posting - * the `close` message, which dismisses via the standard manual-close path. - */ -@Serializable -data class BackButtonInputEvent( - @SerialName("event_name") - val eventName: String = "back_button_input", -) 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/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 550b8633b..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 @@ -21,7 +21,6 @@ import com.superwall.sdk.paywall.view.delegate.PaywallViewEventCallback import com.superwall.sdk.paywall.view.webview.PaywallUIDelegate import com.superwall.sdk.paywall.view.webview.PaywallWebUI import com.superwall.sdk.paywall.view.webview.SendPaywallMessages -import com.superwall.sdk.paywall.view.webview.messaging.BackButtonInputEvent import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessage import com.superwall.sdk.paywall.view.webview.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent @@ -239,24 +238,15 @@ class PaywallViewTest { } @Test - fun backButtonPressed_evaluatesJavascriptBridge() { + 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 WebUI receives a back_button_input message") { - val expectedPayload = - Json { - encodeDefaults = true - namingStrategy = JsonNamingStrategy.SnakeCase - }.encodeToString( - BackButtonInputEvent.serializer(), - BackButtonInputEvent(), - ) - assertEquals( - listOf("window.paywall.accept([$expectedPayload])"), - fakeWebUI.evaluateCalls, - ) + Then("the message handler receives BackButtonPressed") { + verify(exactly = 1) { messageHandler.handle(PaywallMessage.BackButtonPressed) } } } } From 37b081b71c5740262c4d072317476a58339bae6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Jul 2026 17:09:03 +0000 Subject: [PATCH 19/21] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 95275ca45..2ea735435 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.2% \ No newline at end of file +branches36.3% \ No newline at end of file From 7c0d31d7431695dd86feef98549a7bdb9a1ee5a1 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Wed, 29 Jul 2026 21:00:41 +0200 Subject: [PATCH 20/21] Keep test store product naming --- CHANGELOG.md | 2 +- .../store/abstractions/product/ApiStoreProduct.kt | 2 +- .../com/superwall/sdk/store/testmode/TestMode.kt | 3 +-- .../sdk/store/testmode/TestStoreProduct.kt | 14 ++++++++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0ae00f7..19b114a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superw - 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. -- Renames `TestStoreProduct` to `ApiStoreProduct`, now shared between test mode and custom store products. +- 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. 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 index 3a47afafb..f7fcbf266 100644 --- 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 @@ -16,7 +16,7 @@ import java.util.Locale * Shared between test mode products and custom (CUSTOM-store) products that are not * fetched from Google Play. */ -class ApiStoreProduct( +open class ApiStoreProduct( private val superwallProduct: SuperwallProduct, ) : StoreProductType { /** The platform this product belongs to, as reported by the /products endpoint. */ diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt index 2699e365f..cef1689fa 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt @@ -18,7 +18,6 @@ import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.StoredTestModeSettings import com.superwall.sdk.storage.TestModeSettings import com.superwall.sdk.store.Entitlements -import com.superwall.sdk.store.abstractions.product.ApiStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.testmode.models.SuperwallEntitlementRef import com.superwall.sdk.store.testmode.models.SuperwallProduct @@ -327,7 +326,7 @@ class TestMode( val productsByFullId = androidProducts.associate { superwallProduct -> - val testProduct = ApiStoreProduct(superwallProduct) + val testProduct = TestStoreProduct(superwallProduct) superwallProduct.identifier to StoreProduct(testProduct) } setTestProducts(productsByFullId) 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 new file mode 100644 index 000000000..11c61b5d7 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt @@ -0,0 +1,14 @@ +package com.superwall.sdk.store.testmode + +import com.superwall.sdk.store.abstractions.product.ApiStoreProduct +import com.superwall.sdk.store.testmode.models.SuperwallProduct + +/** + * 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( + superwallProduct: SuperwallProduct, +) : ApiStoreProduct(superwallProduct) From 2cfa2db48f71b7bba684158f07259d9c0983ae59 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Wed, 29 Jul 2026 21:01:37 +0200 Subject: [PATCH 21/21] Update options back from dev --- .../java/com/superwall/sdk/config/options/SuperwallOptions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt index 5124f69c3..abb2abda7 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt @@ -27,7 +27,7 @@ class SuperwallOptions() { open val hostDomain: String, ) { open val baseHost: String - get() = "ir-feat-custom-store-and.prd.us-east-1.review-lab.superwall-services.com" + get() = "api.$hostDomain" open val collectorHost: String get() = "collector.$hostDomain"