From 6354b93292f471d556a1255534297f912a3bb04c Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Wed, 30 Oct 2024 13:35:35 +0000 Subject: [PATCH] [Experimental] Web purchase redemption (#1889) ### Description This adds experimental support to redeeming anonymous web purchases performed through RCBilling. The main API changes here are: - Adds a new `Purchases.parseAsDeepLink` that parses the intent and returns a deep link if it needs to be handled - Adds a new `Purchases.sharedInstance.redeemWebPurchase` that uses a parsed deep link to perform the redemption. - Adds a new `DeepLink` sealed class with the types of deep links - Adds a new `RedeemWebPurchaseListener` to listen to the result of the redemption. - Adds a new `RedeemWebPurchaseListener.Result` sealed class with the result of the redemption. All these are currently experimental APIs --- .../apitester/java/PurchasesAPI.java | 15 +- .../java/RedeemWebPurchaseListenerAPI.java | 27 +++ .../apitester/kotlin/IntentExtensionsAPI.kt | 14 ++ .../apitester/kotlin/PurchasesAPI.kt | 15 ++ .../kotlin/RedeemWebPurchaseListenerAPI.kt | 32 +++ .../src/main/AndroidManifest.xml | 8 + .../revenuecat/purchasetester/MainActivity.kt | 18 ++ .../purchasetester/OverviewFragment.kt | 23 +++ .../com/revenuecat/purchases/Purchases.kt | 11 ++ .../revenuecat/purchases/IntentExtensions.kt | 9 + .../com/revenuecat/purchases/Purchases.kt | 35 ++++ .../purchases/PurchasesOrchestrator.kt | 17 ++ .../revenuecat/purchases/common/Backend.kt | 56 ++++++ .../purchases/common/networking/Endpoint.kt | 8 + .../purchases/deeplinks/DeepLinkParser.kt | 26 +++ .../deeplinks/WebPurchaseRedemptionHelper.kt | 59 ++++++ .../interfaces/RedeemWebPurchaseListener.kt | 33 ++++ .../revenuecat/purchases/BasePurchasesTest.kt | 13 +- .../backend/BackendRedeemWebPurchaseTest.kt | 184 ++++++++++++++++++ .../common/networking/EndpointTest.kt | 10 + .../WebPurchaseRedemptionHelperTest.kt | 115 +++++++++++ .../com/revenuecat/purchases/PurchasesTest.kt | 36 ++++ 22 files changed, 761 insertions(+), 3 deletions(-) create mode 100644 api-tester/src/defaults/java/com/revenuecat/apitester/java/RedeemWebPurchaseListenerAPI.java create mode 100644 api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/IntentExtensionsAPI.kt create mode 100644 api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/RedeemWebPurchaseListenerAPI.kt create mode 100644 purchases/src/defaults/kotlin/com/revenuecat/purchases/IntentExtensions.kt create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/DeepLinkParser.kt create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper.kt create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/interfaces/RedeemWebPurchaseListener.kt create mode 100644 purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt create mode 100644 purchases/src/test/java/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelperTest.kt diff --git a/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java b/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java index 6edeecf4cd..fbc88cf3bc 100644 --- a/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java +++ b/api-tester/src/defaults/java/com/revenuecat/apitester/java/PurchasesAPI.java @@ -1,13 +1,16 @@ package com.revenuecat.apitester.java; import android.content.Context; +import android.content.Intent; import androidx.annotation.NonNull; +import androidx.annotation.OptIn; import com.revenuecat.purchases.AmazonLWAConsentStatus; import com.revenuecat.purchases.CacheFetchPolicy; import com.revenuecat.purchases.CustomerInfo; import com.revenuecat.purchases.EntitlementVerificationMode; +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI; import com.revenuecat.purchases.Offerings; import com.revenuecat.purchases.Purchases; import com.revenuecat.purchases.PurchasesAreCompletedBy; @@ -18,6 +21,7 @@ import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback; import com.revenuecat.purchases.interfaces.LogInCallback; import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback; +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener; import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback; import com.revenuecat.purchases.interfaces.SyncPurchasesCallback; @@ -26,9 +30,15 @@ import java.util.Map; import java.util.concurrent.ExecutorService; +@OptIn(markerClass = ExperimentalPreviewRevenueCatPurchasesAPI.class) @SuppressWarnings({"unused"}) final class PurchasesAPI { - static void check(final Purchases purchases) { + static void check( + final Purchases purchases, + final Purchases.DeepLink.WebPurchaseRedemption deepLink, + final RedeemWebPurchaseListener redeemWebPurchaseListener, + final Intent intent + ) { final ReceiveCustomerInfoCallback receiveCustomerInfoListener = new ReceiveCustomerInfoCallback() { @Override public void onReceived(@NonNull CustomerInfo customerInfo) { @@ -85,6 +95,7 @@ public void onSuccess(@NonNull AmazonLWAConsentStatus contentStatus) { purchases.getCustomerInfo(receiveCustomerInfoListener); purchases.getCustomerInfo(CacheFetchPolicy.CACHED_OR_FETCHED, receiveCustomerInfoListener); purchases.getAmazonLWAConsentStatus(getAmazonLWAContentStatusCallback); + purchases.redeemWebPurchase(deepLink, redeemWebPurchaseListener); purchases.restorePurchases(receiveCustomerInfoListener); purchases.invalidateCustomerInfoCache(); @@ -102,6 +113,8 @@ public void onSuccess(@NonNull AmazonLWAConsentStatus contentStatus) { final String storefrontCountryCode = purchases.getStorefrontCountryCode(); final PurchasesConfiguration configuration = purchases.getCurrentConfiguration(); + + final Purchases.DeepLink parsedDeepLink = Purchases.parseAsDeepLink(intent); } static void check(final Purchases purchases, final Map attributes) { diff --git a/api-tester/src/defaults/java/com/revenuecat/apitester/java/RedeemWebPurchaseListenerAPI.java b/api-tester/src/defaults/java/com/revenuecat/apitester/java/RedeemWebPurchaseListenerAPI.java new file mode 100644 index 0000000000..d4139c572b --- /dev/null +++ b/api-tester/src/defaults/java/com/revenuecat/apitester/java/RedeemWebPurchaseListenerAPI.java @@ -0,0 +1,27 @@ +package com.revenuecat.apitester.java; + +import androidx.annotation.OptIn; + +import com.revenuecat.purchases.CustomerInfo; +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI; +import com.revenuecat.purchases.PurchasesError; +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener; + +@OptIn(markerClass = ExperimentalPreviewRevenueCatPurchasesAPI.class) +@SuppressWarnings({"unused"}) +final class RedeemWebPurchaseListenerAPI { + static void checkListener(RedeemWebPurchaseListener listener, + RedeemWebPurchaseListener.Result result) { + listener.handleResult(result); + } + + static void checkRedeemResult(RedeemWebPurchaseListener.Result result) { + if (result instanceof RedeemWebPurchaseListener.Result.Success) { + CustomerInfo customerInfo = ((RedeemWebPurchaseListener.Result.Success) result).getCustomerInfo(); + } else if (result instanceof RedeemWebPurchaseListener.Result.Error) { + PurchasesError error = ((RedeemWebPurchaseListener.Result.Error) result).getError(); + } + + boolean isSuccess = result.isSuccess(); + } +} diff --git a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/IntentExtensionsAPI.kt b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/IntentExtensionsAPI.kt new file mode 100644 index 0000000000..255da9330e --- /dev/null +++ b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/IntentExtensionsAPI.kt @@ -0,0 +1,14 @@ +package com.revenuecat.apitester.kotlin + +import android.content.Intent +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.parseAsDeepLink + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Suppress("unused", "UNUSED_VARIABLE") +private class IntentExtensionsAPI { + fun check(intent: Intent) { + val deepLink: Purchases.DeepLink? = intent.parseAsDeepLink() + } +} diff --git a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt index 107d207a75..8795b26172 100644 --- a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt +++ b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/PurchasesAPI.kt @@ -1,6 +1,7 @@ package com.revenuecat.apitester.kotlin import android.content.Context +import android.content.Intent import com.revenuecat.purchases.AmazonLWAConsentStatus import com.revenuecat.purchases.CacheFetchPolicy import com.revenuecat.purchases.CustomerInfo @@ -28,6 +29,7 @@ import com.revenuecat.purchases.getCustomerInfoWith import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback import com.revenuecat.purchases.interfaces.SyncPurchasesCallback import com.revenuecat.purchases.logInWith @@ -37,11 +39,15 @@ import com.revenuecat.purchases.syncAttributesAndOfferingsIfNeededWith import com.revenuecat.purchases.syncPurchasesWith import java.util.concurrent.ExecutorService +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @Suppress("unused", "UNUSED_VARIABLE", "EmptyFunctionBlock", "DEPRECATION") private class PurchasesAPI { @SuppressWarnings("LongParameterList") fun check( purchases: Purchases, + deepLink: Purchases.DeepLink.WebPurchaseRedemption, + redeemWebPurchaseListener: RedeemWebPurchaseListener, + intent: Intent, ) { val receiveCustomerInfoCallback = object : ReceiveCustomerInfoCallback { override fun onReceived(customerInfo: CustomerInfo) {} @@ -92,6 +98,15 @@ private class PurchasesAPI { val countryCode = purchases.storefrontCountryCode val configuration: PurchasesConfiguration = purchases.currentConfiguration + + purchases.redeemWebPurchase(deepLink, redeemWebPurchaseListener) + val parsedDeepLink: Purchases.DeepLink? = Purchases.parseAsDeepLink(intent) + } + + fun checkDeepLink(deepLink: Purchases.DeepLink): Boolean { + when (deepLink) { + is Purchases.DeepLink.WebPurchaseRedemption -> return true + } } @Suppress("LongMethod", "LongParameterList") diff --git a/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/RedeemWebPurchaseListenerAPI.kt b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/RedeemWebPurchaseListenerAPI.kt new file mode 100644 index 0000000000..7a1e945c0e --- /dev/null +++ b/api-tester/src/defaults/kotlin/com/revenuecat/apitester/kotlin/RedeemWebPurchaseListenerAPI.kt @@ -0,0 +1,32 @@ +package com.revenuecat.apitester.kotlin + +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Suppress("unused", "UNUSED_VARIABLE") +private class RedeemWebPurchaseListenerAPI { + fun checkListener( + redeemWebPurchaseListener: RedeemWebPurchaseListener, + result: RedeemWebPurchaseListener.Result, + ) { + redeemWebPurchaseListener.handleResult(result) + } + + fun checkResult(result: RedeemWebPurchaseListener.Result): Boolean { + val isSuccess: Boolean = result.isSuccess + + when (result) { + is RedeemWebPurchaseListener.Result.Success -> { + val customerInfo: CustomerInfo = result.customerInfo + return true + } + is RedeemWebPurchaseListener.Result.Error -> { + val error: PurchasesError = result.error + return false + } + } + } +} diff --git a/examples/purchase-tester/src/main/AndroidManifest.xml b/examples/purchase-tester/src/main/AndroidManifest.xml index 788c99cc8b..31db952516 100644 --- a/examples/purchase-tester/src/main/AndroidManifest.xml +++ b/examples/purchase-tester/src/main/AndroidManifest.xml @@ -15,12 +15,20 @@ android:theme="@style/AppTheme"> + + + + + + + diff --git a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/MainActivity.kt b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/MainActivity.kt index 030de858c6..c991bd6159 100644 --- a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/MainActivity.kt +++ b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/MainActivity.kt @@ -1,13 +1,31 @@ package com.revenuecat.purchasetester +import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.Purchases import com.revenuecat.purchases_sample.R +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) class MainActivity : AppCompatActivity() { + internal var rcDeepLink: Purchases.DeepLink? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + rcDeepLink = Purchases.parseAsDeepLink(intent) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + if (intent != null) { + rcDeepLink = Purchases.parseAsDeepLink(intent) + } + } + + fun clearDeepLink() { + rcDeepLink = null } } diff --git a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewFragment.kt b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewFragment.kt index cf50b59ebc..9ad53127db 100644 --- a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewFragment.kt +++ b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OverviewFragment.kt @@ -20,6 +20,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.transition.MaterialElevationScale import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.Offering import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.PurchaseParams @@ -29,6 +30,7 @@ import com.revenuecat.purchases.getAmazonLWAConsentStatusWith import com.revenuecat.purchases.getOfferingsWith import com.revenuecat.purchases.interfaces.GetStoreProductsCallback import com.revenuecat.purchases.interfaces.PurchaseCallback +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback import com.revenuecat.purchases.logOutWith import com.revenuecat.purchases.models.GoogleStoreProduct @@ -113,6 +115,27 @@ class OverviewFragment : Fragment(), OfferingCardAdapter.OfferingCardAdapterList } } + @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) + override fun onResume() { + super.onResume() + + val activity = requireActivity() as MainActivity + val deepLink = activity.rcDeepLink ?: return + if (deepLink is Purchases.DeepLink.WebPurchaseRedemption) { + activity.clearDeepLink() + Purchases.sharedInstance.redeemWebPurchase(deepLink) { result -> + when (result) { + is RedeemWebPurchaseListener.Result.Success -> { + showToast("Successfully redeemed web purchase. Updating customer info.") + } + is RedeemWebPurchaseListener.Result.Error -> { + showUserError(requireActivity(), result.error) + } + } + } + } + } + private fun populateOfferings(offerings: Offerings) { if (offerings.all.isEmpty()) { binding.offeringHeader.text = "No Offerings" diff --git a/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt index 669af246c7..94a84eeb5a 100644 --- a/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/customEntitlementComputation/kotlin/com/revenuecat/purchases/Purchases.kt @@ -181,6 +181,17 @@ class Purchases internal constructor( } //endregion + /** + * Represents a valid RevenueCat deep link. + */ + @ExperimentalPreviewRevenueCatPurchasesAPI + sealed interface DeepLink { + /** + * Represents a web redemption link, that can be redeemed using [Purchases.redeemWebPurchase] + */ + class WebPurchaseRedemption internal constructor(internal val redemptionToken: String) : DeepLink + } + // region Static companion object { diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/IntentExtensions.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/IntentExtensions.kt new file mode 100644 index 0000000000..dd6853fb8f --- /dev/null +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/IntentExtensions.kt @@ -0,0 +1,9 @@ +package com.revenuecat.purchases + +import android.content.Intent + +@ExperimentalPreviewRevenueCatPurchasesAPI +@JvmSynthetic +fun Intent.parseAsDeepLink(): Purchases.DeepLink? { + return Purchases.parseAsDeepLink(this) +} diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt index ec68299db4..fa765db4e8 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt @@ -3,11 +3,13 @@ package com.revenuecat.purchases import android.annotation.SuppressLint import android.app.Activity import android.content.Context +import android.content.Intent import androidx.annotation.VisibleForTesting import com.revenuecat.purchases.common.LogIntent import com.revenuecat.purchases.common.PlatformInfo import com.revenuecat.purchases.common.infoLog import com.revenuecat.purchases.common.log +import com.revenuecat.purchases.deeplinks.DeepLinkParser import com.revenuecat.purchases.interfaces.Callback import com.revenuecat.purchases.interfaces.GetAmazonLWAConsentStatusCallback import com.revenuecat.purchases.interfaces.GetCustomerCenterConfigCallback @@ -16,6 +18,7 @@ import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback import com.revenuecat.purchases.interfaces.SyncPurchasesCallback import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener @@ -788,9 +791,41 @@ class Purchases internal constructor( } // endregion + /** + * Redeem a web purchase using a [DeepLink.WebPurchaseRedemption] object obtained + * through [Purchases.parseAsDeepLink]. + */ + @ExperimentalPreviewRevenueCatPurchasesAPI + fun redeemWebPurchase(webPurchaseRedemption: DeepLink.WebPurchaseRedemption, listener: RedeemWebPurchaseListener) { + purchasesOrchestrator.redeemWebPurchase(webPurchaseRedemption, listener) + } + + /** + * Represents a valid RevenueCat deep link. + */ + @ExperimentalPreviewRevenueCatPurchasesAPI + sealed interface DeepLink { + /** + * Represents a web redemption link, that can be redeemed using [Purchases.redeemWebPurchase] + */ + class WebPurchaseRedemption internal constructor(internal val redemptionToken: String) : DeepLink + } + // region Static companion object { + /** + * Given an intent, parses the deep link if any and returns a parsed version of it. + * Currently supports web redemption links. + * @return A parsed version of the deep link or null if it's not a valid RevenueCat deep link. + */ + @ExperimentalPreviewRevenueCatPurchasesAPI + @JvmStatic + fun parseAsDeepLink(intent: Intent): DeepLink? { + val intentData = intent.data ?: return null + return DeepLinkParser.parseDeepLink(intentData) + } + /** * DO NOT MODIFY. This is used internally by the Hybrid SDKs to indicate which platform is * being used diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index 655d3490f9..1bfa5dc0c0 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -34,6 +34,7 @@ import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsMa import com.revenuecat.purchases.common.sha1 import com.revenuecat.purchases.common.subscriberattributes.SubscriberAttributeKey import com.revenuecat.purchases.common.warnLog +import com.revenuecat.purchases.deeplinks.WebPurchaseRedemptionHelper import com.revenuecat.purchases.google.isSuccessful import com.revenuecat.purchases.identity.IdentityManager import com.revenuecat.purchases.interfaces.Callback @@ -46,6 +47,7 @@ import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.PurchaseErrorCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback import com.revenuecat.purchases.interfaces.SyncPurchasesCallback import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener @@ -102,6 +104,13 @@ internal class PurchasesOrchestrator( private val mainHandler: Handler? = Handler(Looper.getMainLooper()), private val dispatcher: Dispatcher, private val initialConfiguration: PurchasesConfiguration, + private val webPurchaseRedemptionHelper: WebPurchaseRedemptionHelper = + WebPurchaseRedemptionHelper( + backend, + identityManager, + offlineEntitlementsManager, + customerInfoUpdateHandler, + ), ) : LifecycleDelegate, CustomActivityLifecycleHandler { internal var state: PurchasesState @@ -237,6 +246,14 @@ internal class PurchasesOrchestrator( } } + @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) + fun redeemWebPurchase( + webPurchaseRedemption: Purchases.DeepLink.WebPurchaseRedemption, + listener: RedeemWebPurchaseListener, + ) { + webPurchaseRedemptionHelper.handleRedeemWebPurchase(webPurchaseRedemption, listener) + } + // region Public Methods fun syncAttributesAndOfferingsIfNeeded( diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt index 48e791fd64..d92c06a76a 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt @@ -76,6 +76,8 @@ internal typealias ProductEntitlementCallback = Pair<(ProductEntitlementMapping) @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) internal typealias CustomerCenterCallback = Pair<(CustomerCenterConfigData) -> Unit, (PurchasesError) -> Unit> +internal typealias RedeemWebPurchaseCallback = Pair<(CustomerInfo) -> Unit, (PurchasesError) -> Unit> + internal enum class PostReceiptErrorHandlingBehavior { SHOULD_BE_MARKED_SYNCED, SHOULD_USE_OFFLINE_ENTITLEMENTS_AND_NOT_CONSUME, @@ -129,6 +131,9 @@ internal class Backend( @get:Synchronized @set:Synchronized @Volatile var customerCenterCallbacks = mutableMapOf>() + @get:Synchronized @set:Synchronized + @Volatile var redeemWebPurchaseCallbacks = mutableMapOf>() + fun close() { this.dispatcher.close() } @@ -656,6 +661,57 @@ internal class Backend( } } + fun postRedeemWebPurchase( + appUserID: String, + redemptionToken: String, + onSuccessHandler: (CustomerInfo) -> Unit, + onErrorHandler: (PurchasesError) -> Unit, + ) { + val endpoint = Endpoint.PostRedeemWebPurchase(appUserID) + val path = endpoint.getPath() + val body = mapOf("redemption_token" to redemptionToken) + val call = object : Dispatcher.AsyncCall() { + override fun call(): HTTPResult { + return httpClient.performRequest( + appConfig.baseURL, + endpoint, + body, + postFieldsToSign = null, + backendHelper.authenticationHeaders, + ) + } + + override fun onError(error: PurchasesError) { + synchronized(this@Backend) { + redeemWebPurchaseCallbacks.remove(path) + }?.forEach { (_, onErrorHandler) -> + onErrorHandler(error) + } + } + + override fun onCompletion(result: HTTPResult) { + synchronized(this@Backend) { + redeemWebPurchaseCallbacks.remove(path) + }?.forEach { (onSuccessHandler, onErrorHandler) -> + if (result.isSuccessful()) { + onSuccessHandler(CustomerInfoFactory.buildCustomerInfo(result)) + } else { + onErrorHandler(result.toPurchasesError().also { errorLog(it) }) + } + } + } + } + synchronized(this@Backend) { + redeemWebPurchaseCallbacks.addCallback( + call, + dispatcher, + path, + onSuccessHandler to onErrorHandler, + Delay.NONE, + ) + } + } + fun clearCaches() { httpClient.clearCaches() } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt index fb6d19a4e5..8dedc0ace1 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt @@ -40,6 +40,12 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { ) { override fun getPath() = pathTemplate.format(Uri.encode(userId)) } + data class PostRedeemWebPurchase(val userId: String) : Endpoint( + "/subscribers/%s/redeem_web_purchase", + "post_redeem_web_purchase", + ) { + override fun getPath() = pathTemplate.format(Uri.encode(userId)) + } val supportsSignatureVerification: Boolean get() = when (this) { @@ -48,6 +54,7 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { PostReceipt, is GetOfferings, GetProductEntitlementMapping, + is PostRedeemWebPurchase, -> true is GetAmazonReceipt, @@ -64,6 +71,7 @@ internal sealed class Endpoint(val pathTemplate: String, val name: String) { is GetCustomerInfo, LogIn, PostReceipt, + is PostRedeemWebPurchase, -> true is GetAmazonReceipt, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/DeepLinkParser.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/DeepLinkParser.kt new file mode 100644 index 0000000000..35331e77a3 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/DeepLinkParser.kt @@ -0,0 +1,26 @@ +package com.revenuecat.purchases.deeplinks + +import android.net.Uri +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.common.debugLog + +internal object DeepLinkParser { + private const val REDEEM_WEB_PURCHASE_HOST = "redeem_web_purchase" + + @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) + @Suppress("ReturnCount") + fun parseDeepLink(data: Uri): Purchases.DeepLink? { + if (data.host == REDEEM_WEB_PURCHASE_HOST) { + val redemptionToken = data.getQueryParameter("redemption_token") + if (redemptionToken.isNullOrBlank()) { + debugLog("Redemption token is missing web redemption deep link. Ignoring.") + return null + } + return Purchases.DeepLink.WebPurchaseRedemption(redemptionToken) + } else { + debugLog("Unrecognized deep link host: ${data.host}. Ignoring") + return null + } + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper.kt new file mode 100644 index 0000000000..aea213c80d --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelper.kt @@ -0,0 +1,59 @@ +package com.revenuecat.purchases.deeplinks + +import android.os.Handler +import android.os.Looper +import com.revenuecat.purchases.CustomerInfoUpdateHandler +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.debugLog +import com.revenuecat.purchases.common.errorLog +import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager +import com.revenuecat.purchases.identity.IdentityManager +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +internal class WebPurchaseRedemptionHelper( + private val backend: Backend, + private val identityManager: IdentityManager, + private val offlineEntitlementsManager: OfflineEntitlementsManager, + private val customerInfoUpdateHandler: CustomerInfoUpdateHandler, + private val mainHandler: Handler? = Handler(Looper.getMainLooper()), +) { + fun handleRedeemWebPurchase( + deepLink: Purchases.DeepLink.WebPurchaseRedemption, + listener: RedeemWebPurchaseListener, + ) { + debugLog("Starting web purchase redemption.") + backend.postRedeemWebPurchase( + identityManager.currentAppUserID, + deepLink.redemptionToken, + onErrorHandler = { + errorLog("Error redeeming web purchase: $it") + dispatchResult(listener, RedeemWebPurchaseListener.Result.Error(it)) + }, + onSuccessHandler = { + debugLog("Successfully redeemed web purchase. Updating customer info.") + offlineEntitlementsManager.resetOfflineCustomerInfoCache() + customerInfoUpdateHandler.cacheAndNotifyListeners(it) + dispatchResult(listener, RedeemWebPurchaseListener.Result.Success(it)) + }, + ) + } + + private fun dispatchResult( + resultListener: RedeemWebPurchaseListener, + result: RedeemWebPurchaseListener.Result, + ) { + dispatch { resultListener.handleResult(result) } + } + + private fun dispatch(action: () -> Unit) { + if (Thread.currentThread() != Looper.getMainLooper().thread) { + val handler = mainHandler ?: Handler(Looper.getMainLooper()) + handler.post(action) + } else { + action() + } + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/interfaces/RedeemWebPurchaseListener.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/interfaces/RedeemWebPurchaseListener.kt new file mode 100644 index 0000000000..6fa94367d6 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/interfaces/RedeemWebPurchaseListener.kt @@ -0,0 +1,33 @@ +package com.revenuecat.purchases.interfaces + +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.PurchasesError + +/** + * Interface to handle the redemption of a RevenueCat Web purchase. + */ +@ExperimentalPreviewRevenueCatPurchasesAPI +fun interface RedeemWebPurchaseListener { + /** + * Result of the redemption of a RevenueCat Web purchase. + */ + sealed class Result { + data class Success(val customerInfo: CustomerInfo) : Result() + data class Error(val error: PurchasesError) : Result() + + /** + * Whether the redemption was successful or not. + */ + val isSuccess: Boolean + get() = when (this) { + is Success -> true + is Error -> false + } + } + + /** + * Called when a RevenueCat Web purchase redemption finishes with the result of the operation. + */ + fun handleResult(result: Result) +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt index 8f8e8dac83..59e7e59f46 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt @@ -20,6 +20,7 @@ import com.revenuecat.purchases.common.caching.DeviceCache import com.revenuecat.purchases.common.diagnostics.DiagnosticsSynchronizer import com.revenuecat.purchases.common.offerings.OfferingsManager import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager +import com.revenuecat.purchases.deeplinks.WebPurchaseRedemptionHelper import com.revenuecat.purchases.google.toInAppStoreProduct import com.revenuecat.purchases.google.toStoreTransaction import com.revenuecat.purchases.identity.IdentityManager @@ -74,6 +75,7 @@ internal open class BasePurchasesTest { internal val mockSyncPurchasesHelper = mockk() protected val mockOfferingsManager = mockk() internal val mockPaywallEventsManager = mockk() + internal val mockWebPurchasesRedemptionHelper = mockk() private val purchasesStateProvider = PurchasesStateCache(PurchasesState()) protected var capturedPurchasesUpdatedListener = slot() @@ -92,6 +94,9 @@ internal open class BasePurchasesTest { protected val mockActivity: Activity = mockk() protected val subscriptionOptionId = "mock-base-plan-id:mock-offer-id" + protected open val shouldConfigureOnSetUp: Boolean + get() = true + @Before fun setUp() { mockkStatic(ProcessLifecycleOwner::class) @@ -123,7 +128,9 @@ internal open class BasePurchasesTest { mockPaywallEventsManager.flushEvents() } just Runs - anonymousSetup(false) + if (shouldConfigureOnSetUp) { + anonymousSetup(false) + } } @After @@ -137,6 +144,7 @@ internal open class BasePurchasesTest { mockCustomerInfoUpdateHandler, mockPostPendingTransactionsHelper, mockPaywallEventsManager, + mockWebPurchasesRedemptionHelper, ) unmockkStatic(ProcessLifecycleOwner::class) } @@ -411,7 +419,8 @@ internal open class BasePurchasesTest { paywallPresentedCache = paywallPresentedCache, purchasesStateCache = purchasesStateProvider, dispatcher = SyncDispatcher(), - initialConfiguration = PurchasesConfiguration.Builder(mockContext, "api_key").build() + initialConfiguration = PurchasesConfiguration.Builder(mockContext, "api_key").build(), + webPurchaseRedemptionHelper = mockWebPurchasesRedemptionHelper, ) purchases = Purchases(purchasesOrchestrator) Purchases.sharedInstance = purchases diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt new file mode 100644 index 0000000000..fd0ed1f101 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendRedeemWebPurchaseTest.kt @@ -0,0 +1,184 @@ +package com.revenuecat.purchases.common.backend + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.VerificationResult +import com.revenuecat.purchases.common.AppConfig +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.BackendHelper +import com.revenuecat.purchases.common.Dispatcher +import com.revenuecat.purchases.common.HTTPClient +import com.revenuecat.purchases.common.SyncDispatcher +import com.revenuecat.purchases.common.createCustomerInfo +import com.revenuecat.purchases.common.networking.Endpoint +import com.revenuecat.purchases.common.networking.HTTPResult +import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData.HelpPath +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData.Screen +import com.revenuecat.purchases.customercenter.CustomerCenterConfigData.Screen.ScreenType +import com.revenuecat.purchases.customercenter.RCColor +import com.revenuecat.purchases.utils.Responses +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.net.URL +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@RunWith(AndroidJUnit4::class) +class BackendRedeemWebPurchaseTest { + + private val mockBaseURL = URL("http://mock-api-test.revenuecat.com/") + + private lateinit var appConfig: AppConfig + private lateinit var httpClient: HTTPClient + + private lateinit var backend: Backend + private lateinit var asyncBackend: Backend + + private val expectedCustomerInfo = createCustomerInfo(Responses.validFullPurchaserResponse) + + @Before + fun setUp() { + appConfig = mockk().apply { + every { baseURL } returns mockBaseURL + } + httpClient = mockk() + val backendHelper = BackendHelper("TEST_API_KEY", SyncDispatcher(), appConfig, httpClient) + + val asyncDispatcher1 = createAsyncDispatcher() + val asyncDispatcher2 = createAsyncDispatcher() + + val asyncBackendHelper = BackendHelper("TEST_API_KEY", asyncDispatcher1, appConfig, httpClient) + + backend = Backend( + appConfig, + SyncDispatcher(), + SyncDispatcher(), + httpClient, + backendHelper, + ) + + asyncBackend = Backend( + appConfig, + asyncDispatcher1, + asyncDispatcher2, + httpClient, + asyncBackendHelper, + ) + } + + @Test + fun `postRedeemWebPurchase posts correctly`() { + mockHttpResult() + var receivedCustomerInfo: CustomerInfo? = null + backend.postRedeemWebPurchase( + appUserID = "test-user-id", + redemptionToken = "test-redemption-token", + onSuccessHandler = { receivedCustomerInfo = it }, + onErrorHandler = { error -> fail("Expected success. Got error: $error") }, + ) + assertThat(receivedCustomerInfo).isEqualTo(expectedCustomerInfo) + } + + @Test + fun `postRedeemWebPurchase errors propagate correctly`() { + mockHttpResult(responseCode = RCHTTPStatusCodes.ERROR) + var obtainedError: PurchasesError? = null + backend.postRedeemWebPurchase( + appUserID = "test-user-id", + redemptionToken = "test-redemption-token", + onSuccessHandler = { fail("Expected error. Got success") }, + onErrorHandler = { error -> obtainedError = error }, + ) + assertThat(obtainedError).isNotNull + } + + @Test + fun `given multiple postRedeemWebPurchase calls for same token and user ID, only one is triggered`() { + mockHttpResult(delayMs = 200) + val lock = CountDownLatch(2) + asyncBackend.postRedeemWebPurchase( + appUserID = "test-user-id", + redemptionToken = "test-redemption-token", + onSuccessHandler = { + lock.countDown() + }, + onErrorHandler = { + fail("Expected success. Got error: $it") + }, + ) + asyncBackend.postRedeemWebPurchase( + appUserID = "test-user-id", + redemptionToken = "test-redemption-token", + onSuccessHandler = { + lock.countDown() + }, + onErrorHandler = { + fail("Expected success. Got error: $it") + }, + ) + lock.await(5.seconds.inWholeSeconds, TimeUnit.SECONDS) + assertThat(lock.count).isEqualTo(0) + verify(exactly = 1) { + httpClient.performRequest( + mockBaseURL, + Endpoint.PostRedeemWebPurchase("test-user-id"), + body = mapOf("redemption_token" to "test-redemption-token"), + postFieldsToSign = null, + any() + ) + } + } + + private fun mockHttpResult( + responseCode: Int = RCHTTPStatusCodes.SUCCESS, + delayMs: Long? = null + ) { + every { + httpClient.performRequest( + any(), + any(), + any(), + any(), + any(), + ) + } answers { + if (delayMs != null) { + Thread.sleep(delayMs) + } + HTTPResult( + responseCode, + Responses.validFullPurchaserResponse, + HTTPResult.Origin.BACKEND, + requestDate = null, + VerificationResult.NOT_REQUESTED + ) + } + } + + private fun createAsyncDispatcher(): Dispatcher { + return Dispatcher( + ThreadPoolExecutor( + 1, + 2, + 0, + TimeUnit.MILLISECONDS, + LinkedBlockingQueue() + ) + ) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt index 539cd30294..4e4cf702b2 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt @@ -20,6 +20,7 @@ class EndpointTest { Endpoint.PostAttributes("test-user-id"), Endpoint.PostDiagnostics, Endpoint.PostPaywallEvents, + Endpoint.PostRedeemWebPurchase("test-user-id"), ) @Test @@ -85,6 +86,13 @@ class EndpointTest { assertThat(endpoint.getPath()).isEqualTo(expectedPath) } + @Test + fun `PostRedeemWebPurchase has correct path`() { + val endpoint = Endpoint.PostRedeemWebPurchase("test user-id") + val expectedPath = "/subscribers/test%20user-id/redeem_web_purchase" + assertThat(endpoint.getPath()).isEqualTo(expectedPath) + } + @Test fun `supportsSignatureVerification returns true for expected values`() { val expectedSupportsValidationEndpoints = listOf( @@ -93,6 +101,7 @@ class EndpointTest { Endpoint.PostReceipt, Endpoint.GetOfferings("test-user-id"), Endpoint.GetProductEntitlementMapping, + Endpoint.PostRedeemWebPurchase("test-user-id"), ) for (endpoint in expectedSupportsValidationEndpoints) { assertThat(endpoint.supportsSignatureVerification) @@ -133,6 +142,7 @@ class EndpointTest { Endpoint.GetCustomerInfo("test-user-id"), Endpoint.LogIn, Endpoint.PostReceipt, + Endpoint.PostRedeemWebPurchase("test-user-id"), ) for (endpoint in expectedEndpoints) { assertThat(endpoint.needsNonceToPerformSigning) diff --git a/purchases/src/test/java/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelperTest.kt new file mode 100644 index 0000000000..6376d2df3e --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/deeplinks/WebPurchaseRedemptionHelperTest.kt @@ -0,0 +1,115 @@ +package com.revenuecat.purchases.deeplinks + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.CustomerInfoUpdateHandler +import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager +import com.revenuecat.purchases.identity.IdentityManager +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@RunWith(AndroidJUnit4::class) +class WebPurchaseRedemptionHelperTest { + + private val userId = "test-user-id" + private val redemptionToken = "test-redemption-token" + private val deepLink = Purchases.DeepLink.WebPurchaseRedemption(redemptionToken) + + private lateinit var customerInfo: CustomerInfo + + private lateinit var backend: Backend + private lateinit var identityManager: IdentityManager + private lateinit var offlineEntitlementsManager: OfflineEntitlementsManager + private lateinit var customerInfoUpdateHandler: CustomerInfoUpdateHandler + + private lateinit var webPurchaseRedemptionHelper: WebPurchaseRedemptionHelper + + @Before + fun setUp() { + customerInfo = mockk() + backend = mockk() + identityManager = mockk() + offlineEntitlementsManager = mockk() + customerInfoUpdateHandler = mockk() + + every { identityManager.currentAppUserID } returns userId + every { offlineEntitlementsManager.resetOfflineCustomerInfoCache() } just Runs + every { customerInfoUpdateHandler.cacheAndNotifyListeners(customerInfo) } just Runs + + webPurchaseRedemptionHelper = WebPurchaseRedemptionHelper( + backend = backend, + identityManager = identityManager, + offlineEntitlementsManager = offlineEntitlementsManager, + customerInfoUpdateHandler = customerInfoUpdateHandler, + ) + } + + @Test + fun `handleRedeemWebPurchase posts token and returns success`() { + mockBackendResult(customerInfo = customerInfo) + var result: RedeemWebPurchaseListener.Result? = null + webPurchaseRedemptionHelper.handleRedeemWebPurchase(deepLink) { + result = it + } + assertTrue(result is RedeemWebPurchaseListener.Result.Success) + assertThat((result as RedeemWebPurchaseListener.Result.Success).customerInfo).isEqualTo(customerInfo) + } + + @Test + fun `handleRedeemWebPurchase posts token and resets offline entitlements cache on success`() { + mockBackendResult(customerInfo = customerInfo) + webPurchaseRedemptionHelper.handleRedeemWebPurchase(deepLink) {} + verify(exactly = 1) { offlineEntitlementsManager.resetOfflineCustomerInfoCache() } + } + + @Test + fun `handleRedeemWebPurchase posts token and notifies listener on success`() { + mockBackendResult(customerInfo = customerInfo) + webPurchaseRedemptionHelper.handleRedeemWebPurchase(deepLink) {} + verify(exactly = 1) { customerInfoUpdateHandler.cacheAndNotifyListeners(customerInfo) } + } + + @Test + fun `handleRedeemWebPurchase posts token and returns error`() { + val expectedError = PurchasesError(PurchasesErrorCode.UnknownBackendError) + mockBackendResult(error = expectedError) + var result: RedeemWebPurchaseListener.Result? = null + webPurchaseRedemptionHelper.handleRedeemWebPurchase(deepLink) { + result = it + } + assertTrue(result is RedeemWebPurchaseListener.Result.Error) + assertThat((result as RedeemWebPurchaseListener.Result.Error).error).isEqualTo(expectedError) + verify(exactly = 0) { offlineEntitlementsManager.resetOfflineCustomerInfoCache() } + verify(exactly = 0) { customerInfoUpdateHandler.cacheAndNotifyListeners(any()) } + } + + private fun mockBackendResult( + customerInfo: CustomerInfo? = null, + error: PurchasesError? = null + ) { + if (customerInfo != null) { + every { backend.postRedeemWebPurchase(userId, redemptionToken, captureLambda(), any()) } answers { + lambda<(CustomerInfo) -> Unit>().captured.invoke(customerInfo) + } + } else if (error != null) { + every { backend.postRedeemWebPurchase(userId, redemptionToken, any(), captureLambda()) } answers { + lambda<(PurchasesError) -> Unit>().captured.invoke(error) + } + } + } +} diff --git a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt index fc95e824e1..d2f76d0ae8 100644 --- a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt +++ b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.common.CustomerInfoFactory import com.revenuecat.purchases.common.PlatformInfo import com.revenuecat.purchases.common.ReceiptInfo import com.revenuecat.purchases.common.ReplaceProductInfo +import com.revenuecat.purchases.common.createCustomerInfo import com.revenuecat.purchases.common.sha1 import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.google.toInAppStoreProduct @@ -20,6 +21,7 @@ import com.revenuecat.purchases.interfaces.GetStoreProductsCallback import com.revenuecat.purchases.interfaces.LogInCallback import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback +import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreTransaction @@ -34,6 +36,7 @@ import io.mockk.Runs import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import io.mockk.verifyAll import org.assertj.core.api.Assertions.assertThat @@ -1508,6 +1511,39 @@ internal class PurchasesTest : BasePurchasesTest() { assertThat(receivedError).isEqualTo(expectedError) } + // region redeemWebPurchase + + @Test + fun `redeemWebPurchase is successful if helper returns success`() { + val redemptionLink = Purchases.DeepLink.WebPurchaseRedemption("redemption_token") + val slot = slot() + every { mockWebPurchasesRedemptionHelper.handleRedeemWebPurchase(redemptionLink, capture(slot)) } answers { + slot.captured.handleResult(RedeemWebPurchaseListener.Result.Success(mockInfo)) + } + var result: RedeemWebPurchaseListener.Result? = null + purchases.redeemWebPurchase(redemptionLink) { + result = it + } + assertThat(result).isEqualTo(RedeemWebPurchaseListener.Result.Success(mockInfo)) + } + + @Test + fun `redeemWebPurchase errors if helper returns error`() { + val redemptionLink = Purchases.DeepLink.WebPurchaseRedemption("redemption_token") + val slot = slot() + val expectedError = PurchasesError(PurchasesErrorCode.UnknownBackendError) + every { mockWebPurchasesRedemptionHelper.handleRedeemWebPurchase(redemptionLink, capture(slot)) } answers { + slot.captured.handleResult(RedeemWebPurchaseListener.Result.Error(expectedError)) + } + var result: RedeemWebPurchaseListener.Result? = null + purchases.redeemWebPurchase(redemptionLink) { + result = it + } + assertThat(result).isEqualTo(RedeemWebPurchaseListener.Result.Error(expectedError)) + } + + // endregion redeemWebPurchase + // region Private Methods private fun getMockedPurchaseHistoryList(