Skip to content
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String>): List<StoreProduct> {
Expand Down Expand Up @@ -115,8 +119,10 @@ class RevenueCatPurchaseController(
UpdatedCustomerInfoListener {
private var superwallCustomerInfoJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)
val purchased: MutableList<String>

init {
purchased = mutableListOf<String>()
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
PurchasesConfiguration
Expand Down Expand Up @@ -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))
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = emptyList()

override suspend fun makeIdentityManager(): IdentityManager = mockk()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -400,7 +400,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -525,7 +525,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -581,7 +581,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -631,7 +631,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -676,7 +676,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -722,7 +722,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
any(),
any(),
)
Expand Down Expand Up @@ -911,7 +911,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -957,7 +957,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
any(),
any(),
)
Expand Down Expand Up @@ -1002,7 +1002,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
any(),
any(),
)
Expand Down Expand Up @@ -1050,7 +1050,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down Expand Up @@ -1098,7 +1098,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
any(),
any<StoreProduct>(),
"basePlan",
any(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -26,19 +27,74 @@ interface PurchaseController {
* Add your purchase logic here and return its result. You can use Android's Billing APIs,
* or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)).
*
* @param productDefaults The `ProductDetails` the user would like to purchase.
* @param productDetails The `ProductDetails` the user would like to purchase.
* @param basePlanId An optional base plan identifier of the product that's being purchased.
* @param offerId An optional offer identifier of the product that's being purchased.
* @return A `PurchaseResult` object, which is the result of your purchase logic.
* **Note**: Make sure you handle all cases of `PurchaseResult`.
*/
@Deprecated(
"Implement purchase(activity, product, basePlanId, offerId) instead. " +
"It receives a StoreProduct, which also supports custom store products.",
ReplaceWith("purchase(activity, product, basePlanId, offerId)"),
)
@MainThread
suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
): PurchaseResult
): PurchaseResult =
PurchaseResult.Failed(
"This PurchaseController does not implement a purchase method. " +
"Override purchase(activity, product, basePlanId, offerId).",
)

/**
* Called when the user initiates purchasing of a product.
*
* Add your purchase logic here and return its result. You can use Android's Billing APIs,
* or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)).
*
* For Google Play products, the underlying `ProductDetails` are available via
* [StoreProduct.rawStoreProduct]'s `underlyingProductDetails`.
*
* **Custom products**: when [StoreProduct.isCustomProduct] is true, the product's metadata
* is sourced from the Superwall API rather than Google Play — route the purchase through
* your own payment system (e.g. Stripe, web checkout). The SDK does not grant entitlements
* for custom products: on a successful purchase you must update the user's entitlements
* yourself by calling `Superwall.instance.setSubscriptionStatus(...)`. The product's
* [StoreProduct.customTransactionId] is pre-generated by the SDK and used as the original
* transaction identifier in analytics.
*
* The default implementation routes Google Play products to the deprecated
* `purchase(activity, productDetails, basePlanId, offerId)` so existing implementations
* keep working, and fails for custom products.
*
* @param activity The `Activity` from which the purchase was initiated.
* @param product The Superwall [StoreProduct] the user would like to purchase.
* @param basePlanId An optional base plan identifier of the product that's being purchased.
* @param offerId An optional offer identifier of the product that's being purchased.
* @return A `PurchaseResult` object, which is the result of your purchase logic.
* **Note**: Make sure you handle all cases of `PurchaseResult`.
*/
@MainThread
suspend fun purchase(
activity: Activity,
product: StoreProduct,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
val rawStoreProduct =
product.rawStoreProduct
?: return PurchaseResult.Failed(
"This PurchaseController does not handle custom products. Override " +
"purchase(activity, product, basePlanId, offerId) and handle products " +
"where isCustomProduct == true to purchase ${product.fullIdentifier}.",
)
@Suppress("DEPRECATION")
return purchase(activity, rawStoreProduct.underlyingProductDetails, basePlanId, offerId)
}

/**
* Called when the user initiates a restore.
Expand Down
Loading
Loading