diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 4e60fbf8..3463b671 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -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) } 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 153f9a52..fb21d7c3 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 @@ -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 @@ -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() @@ -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( 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 c02e76b3..0e94f64c 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 @@ -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) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt index 52fee934..36ecb88b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt @@ -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 @@ -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") 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 a3afcb12..827013ad 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt @@ -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 + } + override suspend fun restorePurchases(): RestorationResult { syncSubscriptionStatusAndWait() return RestorationResult.Restored() 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 f74f1253..fabaf2fb 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -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, diff --git a/superwall/src/main/java/com/superwall/sdk/store/ReplacementMode.kt b/superwall/src/main/java/com/superwall/sdk/store/ReplacementMode.kt new file mode 100644 index 00000000..4073e352 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/ReplacementMode.kt @@ -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 } + } +} 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 44cc16a8..179b9f83 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 @@ -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 @@ -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 @@ -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() &&