Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,30 @@ class Superwall(
}
}

is PaywallWebEvent.InitiateReplacement -> {
if (purchaseTask != null) {
return@launchWithTracking
}
paywallView.updateState(
PaywallViewState.Updates.SetLoadingState(PaywallLoadingState.LoadingPurchase),
)
purchaseTask =
launch {
try {
dependencyContainer.transactionManager.purchase(
Internal(
paywallEvent.productId,
paywallView.controller.state,
paywallEvent.replacementMode,
),
shouldDismiss = paywallEvent.shouldDismiss,
)
} finally {
purchaseTask = null
}
}
}

is InitiateRestore -> {
dependencyContainer.transactionManager.tryToRestorePurchases(paywallView)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.superwall.sdk.models.paywall.LocalNotificationType
import com.superwall.sdk.paywall.presentation.CustomCallbackBehavior
import com.superwall.sdk.permissions.PermissionType
import com.superwall.sdk.storage.core_data.convertFromJsonElement
import com.superwall.sdk.store.ReplacementMode
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
Expand Down Expand Up @@ -69,6 +70,13 @@ sealed class PaywallMessage {
val shouldDismiss: Boolean,
) : PaywallMessage()

data class ReplaceProduct(
val product: String,
val productId: String,
val replacementMode: ReplacementMode,
val shouldDismiss: Boolean,
) : PaywallMessage()

data class Custom(
val data: String,
) : PaywallMessage()
Expand Down Expand Up @@ -201,6 +209,21 @@ private fun parsePaywallMessage(json: JsonObject): PaywallMessage {
json["should_dismiss"]?.jsonPrimitive?.booleanOrNull ?: true,
)

"replace_product" -> {
val modeRaw =
json["replacement_mode"]?.jsonPrimitive?.contentOrNull
?: throw IllegalArgumentException("replace_product missing replacement_mode")
val mode =
ReplacementMode.fromRaw(modeRaw)
?: throw IllegalArgumentException("Unknown replacement_mode: $modeRaw")
PaywallMessage.ReplaceProduct(
product = json["product"]!!.jsonPrimitive.content,
productId = json["product_identifier"]!!.jsonPrimitive.content,
replacementMode = mode,
shouldDismiss = json["should_dismiss"]?.jsonPrimitive?.booleanOrNull ?: true,
)
}

"custom" -> PaywallMessage.Custom(json["data"]!!.jsonPrimitive.content)
"custom_placement" ->
PaywallMessage.CustomPlacement(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,18 @@ class PaywallMessageHandler(
shouldDismiss = message.shouldDismiss,
)

is PaywallMessage.ReplaceProduct -> {
detectHiddenPaywallEvent("replace_product")
hapticFeedback()
messageHandler?.eventDidOccur(
PaywallWebEvent.InitiateReplacement(
productId = message.productId,
replacementMode = message.replacementMode,
shouldDismiss = message.shouldDismiss,
),
)
}

is PaywallMessage.PaywallOpen -> {
if (messageHandler?.state?.paywall?.paywalljsVersion == null) {
queue.offer(message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.net.Uri
import com.superwall.sdk.models.paywall.LocalNotification
import com.superwall.sdk.paywall.presentation.CustomCallbackBehavior
import com.superwall.sdk.permissions.PermissionType
import com.superwall.sdk.store.ReplacementMode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
Expand All @@ -19,6 +20,13 @@ sealed class PaywallWebEvent {
val shouldDismiss: Boolean,
) : PaywallWebEvent()

@SerialName("initiate_replacement")
data class InitiateReplacement(
val productId: String,
val replacementMode: ReplacementMode,
val shouldDismiss: Boolean,
) : PaywallWebEvent()

object InitiateRestore : PaywallWebEvent()

@SerialName("custom")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,92 @@ class AutomaticPurchaseController(
return value
}

suspend fun replaceProduct(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
replacementMode: Int,
): PurchaseResult {
purchaseResults.value = null

val fullId =
buildFullId(
subscriptionId = productDetails.productId,
basePlanId = basePlanId,
offerId = offerId,
)

val rawStoreProduct =
RawStoreProduct(
underlyingProductDetails = productDetails,
fullIdentifier = fullId,
basePlanType = BasePlanType.from(basePlanId),
offerType = OfferType.from(offerId),
)

val offerToken =
when (val offer = rawStoreProduct.selectedOffer) {
is RawStoreProduct.SelectedOfferDetails.Subscription -> offer.underlying.offerToken
is RawStoreProduct.SelectedOfferDetails.OneTime -> {
if (offer.purchaseOptionId != null || offerId != null) {
offer.underlying.offerToken
} else {
null
}
}
null -> null
}

val hasOfferToken = !offerToken.isNullOrEmpty()

val productDetailsParams =
BillingFlowParams.ProductDetailsParams
.newBuilder()
.setProductDetails(productDetails)
.also {
if (hasOfferToken) {
it.setOfferToken(offerToken!!)
}
}.build()

isConnected.first { it }

val oldToken =
findActiveSubscriptionToken()
?: return PurchaseResult.Failed("No active subscription found for replacement")

val subscriptionUpdateParams =
BillingFlowParams.SubscriptionUpdateParams
.newBuilder()
.setOldPurchaseToken(oldToken)
.setSubscriptionReplacementMode(replacementMode)
.build()

val flowParams =
BillingFlowParams
.newBuilder()
.apply {
setObfuscatedAccountId(Superwall.instance.externalAccountId)
}.setProductDetailsParamsList(listOf(productDetailsParams))
.setSubscriptionUpdateParams(subscriptionUpdateParams)
.build()

billingClient.launchBillingFlow(activity, flowParams)

val value = purchaseResults.first { it != null } ?: PurchaseResult.Failed("Purchase failed")
return value
}

private suspend fun findActiveSubscriptionToken(): String? {
isConnected.first { it }
val purchases =
queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
return purchases
.firstOrNull { it.purchaseState == Purchase.PurchaseState.PURCHASED }
?.purchaseToken
}
Comment on lines +350 to +357
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Picks arbitrary subscription for replacement

findActiveSubscriptionToken() returns the first subscription with PURCHASED state. When a user has multiple active subscriptions, this may select the wrong one to replace. The replaceProduct method receives productDetails for the new product but has no information about which existing subscription should be replaced.

Consider accepting a target product ID (or the old subscription's product ID) and filtering the purchases to find the matching subscription token. For example:

Suggested change
private suspend fun findActiveSubscriptionToken(): String? {
isConnected.first { it }
val purchases =
queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
return purchases
.firstOrNull { it.purchaseState == Purchase.PurchaseState.PURCHASED }
?.purchaseToken
}
private suspend fun findActiveSubscriptionToken(targetProductId: String? = null): String? {
isConnected.first { it }
val purchases =
queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
return purchases
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
.let { active ->
if (targetProductId != null) {
active.firstOrNull { it.products.contains(targetProductId) }
} else {
active.firstOrNull()
}
}
?.purchaseToken
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
Line: 350-357

Comment:
**Picks arbitrary subscription for replacement**

`findActiveSubscriptionToken()` returns the first subscription with `PURCHASED` state. When a user has multiple active subscriptions, this may select the wrong one to replace. The `replaceProduct` method receives `productDetails` for the *new* product but has no information about which *existing* subscription should be replaced.

Consider accepting a target product ID (or the old subscription's product ID) and filtering the purchases to find the matching subscription token. For example:

```suggestion
    private suspend fun findActiveSubscriptionToken(targetProductId: String? = null): String? {
        isConnected.first { it }
        val purchases =
            queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
        return purchases
            .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
            .let { active ->
                if (targetProductId != null) {
                    active.firstOrNull { it.products.contains(targetProductId) }
                } else {
                    active.firstOrNull()
                }
            }
            ?.purchaseToken
    }
```

How can I resolve this? If you propose a fix, please make it concise.


override suspend fun restorePurchases(): RestorationResult {
syncSubscriptionStatusAndWait()
return RestorationResult.Restored()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class InternalPurchaseController(
val hasInternalPurchaseController: Boolean
get() = kotlinPurchaseController is AutomaticPurchaseController

val automaticPurchaseController: AutomaticPurchaseController?
get() = kotlinPurchaseController as? AutomaticPurchaseController

override suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
Expand Down
17 changes: 17 additions & 0 deletions superwall/src/main/java/com/superwall/sdk/store/ReplacementMode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.superwall.sdk.store

enum class ReplacementMode(
val rawName: String,
val playBillingMode: Int,
) {
DEFAULT("default", 1),
CHARGE_LATER("charge_later", 3),
CHARGE_NOW("charge_now", 5),
CHARGE_DIFFERENCE("charge_difference", 2),
CHARGE_ON_EXPIRE("charge_on_expire", 6),
;

companion object {
fun fromRaw(value: String): ReplacementMode? = entries.firstOrNull { it.rawName == value }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import com.superwall.sdk.storage.EventsQueue
import com.superwall.sdk.storage.PurchasingProductdIds
import com.superwall.sdk.storage.Storage
import com.superwall.sdk.store.PurchasingObserverState
import com.superwall.sdk.store.ReplacementMode
import com.superwall.sdk.store.StoreManager
import com.superwall.sdk.store.abstractions.product.RawStoreProduct
import com.superwall.sdk.store.abstractions.product.StoreProduct
Expand Down Expand Up @@ -87,6 +88,7 @@ class TransactionManager(
data class Internal(
val productId: String,
val state: PaywallViewState,
val replacementMode: ReplacementMode? = null,
) : PurchaseSource() {
val paywallInfo: PaywallInfo
get() = state.info
Expand Down Expand Up @@ -362,12 +364,29 @@ class TransactionManager(

prepareToPurchase(product, purchaseSource)
val result =
storeManager.purchaseController.purchase(
activity = activity,
productDetails = productDetails,
offerId = rawStoreProduct.offerId,
basePlanId = rawStoreProduct.basePlanId,
)
if (
purchaseSource is PurchaseSource.Internal &&
purchaseSource.replacementMode != null &&
storeManager.purchaseController.hasInternalPurchaseController
) {
val automatic =
storeManager.purchaseController.automaticPurchaseController
?: return PurchaseResult.Failed("Internal purchase controller unavailable")
automatic.replaceProduct(
activity = activity,
productDetails = productDetails,
basePlanId = rawStoreProduct.basePlanId,
offerId = rawStoreProduct.offerId,
replacementMode = purchaseSource.replacementMode.playBillingMode,
)
} else {
storeManager.purchaseController.purchase(
activity = activity,
productDetails = productDetails,
offerId = rawStoreProduct.offerId,
basePlanId = rawStoreProduct.basePlanId,
)
}

if (purchaseSource is PurchaseSource.ExternalPurchase &&
factory.makeHasExternalPurchaseController() &&
Expand Down
Loading