From 3db0f22195fff571dc40b73760b008018dcc0012 Mon Sep 17 00:00:00 2001 From: Ilia Pavlovskii Date: Fri, 9 Aug 2024 19:16:56 +0200 Subject: [PATCH] ISSUE-1765: Make suspend coroutine wrappers cancellable --- .../purchases/coroutinesExtensions.kt | 144 ++++++++++++++++++ .../purchases/CoroutinesExtensionsCommon.kt | 116 ++++++++++++++ .../revenuecatui/helpers/HelperFunctions.kt | 8 + 3 files changed, 268 insertions(+) diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt index c79299e9ee..e51bfb23ac 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/coroutinesExtensions.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases import com.revenuecat.purchases.CacheFetchPolicy.CACHED_OR_FETCHED import com.revenuecat.purchases.data.LogInResult +import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -30,6 +31,30 @@ suspend fun Purchases.awaitCustomerInfo( } } +/** + * Get latest available customer info. + * Coroutine friendly version of [Purchases.getCustomerInfo]. + * + * @param fetchPolicy Specifies cache behavior for customer info retrieval (optional). + * Defaults to [CacheFetchPolicy.default]: [CACHED_OR_FETCHED]. + * + * @throws [PurchasesException] with a [PurchasesError] if there's an error retrieving the customer info. + * @return The [CustomerInfo] associated to the current user. + */ +@JvmSynthetic +@Throws(PurchasesException::class) +suspend fun Purchases.awaitCancellableCustomerInfo( + fetchPolicy: CacheFetchPolicy = CacheFetchPolicy.default(), +): CustomerInfo { + return suspendCancellableCoroutine { continuation -> + getCustomerInfoWith( + fetchPolicy, + onSuccess = continuation::resume, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} + /** * This function will change the current appUserID. * Typically this would be used after a log out to identify a new user without calling configure @@ -54,6 +79,30 @@ suspend fun Purchases.awaitLogIn(appUserID: String): LogInResult { } } +/** + * This function will change the current appUserID. + * Typically this would be used after a log out to identify a new user without calling configure + * + * Coroutine friendly version of [Purchases.logIn]. + * + * @param appUserID The new appUserID that should be linked to the currently user + * @throws [PurchasesException] with a [PurchasesError] if there's an error login the customer info. + * @return The [CustomerInfo] associated to the current user. + */ +@JvmSynthetic +@Throws(PurchasesTransactionException::class) +suspend fun Purchases.awaitCancellableLogIn(appUserID: String): LogInResult { + return suspendCancellableCoroutine { continuation -> + logInWith( + appUserID, + onSuccess = { customerInfo, created -> + continuation.resume(LogInResult(customerInfo, created)) + }, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} + /** * Logs out the Purchases client clearing the save appUserID. This will generate a random user * id and save it in the cache. @@ -74,6 +123,26 @@ suspend fun Purchases.awaitLogOut(): CustomerInfo { } } +/** + * Logs out the Purchases client clearing the save appUserID. This will generate a random user + * id and save it in the cache. + * + * Coroutine friendly version of [Purchases.logOut]. + * + * @throws [PurchasesException] with a [PurchasesError] if there's an error login out the user. + * @return The [CustomerInfo] associated to the current user. + */ +@JvmSynthetic +@Throws(PurchasesTransactionException::class) +suspend fun Purchases.awaitCancellableLogOut(): CustomerInfo { + return suspendCancellableCoroutine { continuation -> + logOutWith( + onSuccess = { continuation.resume(it) }, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} + /** * This method will send all the purchases to the RevenueCat backend. Call this when using your own implementation * for subscriptions anytime a sync is needed, such as when migrating existing users to RevenueCat. @@ -95,6 +164,27 @@ suspend fun Purchases.awaitSyncPurchases(): CustomerInfo { } } +/** + * This method will send all the purchases to the RevenueCat backend. Call this when using your own implementation + * for subscriptions anytime a sync is needed, such as when migrating existing users to RevenueCat. + * + * Coroutine friendly version of [Purchases.syncPurchases]. + * + * @throws [PurchasesException] with the first [PurchasesError] found while syncing the purchases. + * @return The [CustomerInfo] associated to the user, after all purchases have been successfully synced. If there are no + * purchases to sync, the customer info will be returned without any changes. + */ +@JvmSynthetic +@Throws(PurchasesException::class) +suspend fun Purchases.awaitCancellableSyncPurchases(): CustomerInfo { + return suspendCancellableCoroutine { continuation -> + syncPurchasesWith( + onSuccess = continuation::resume, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} + /** * Syncs subscriber attributes and then fetches the configured offerings for this user. This method is intended to * be called when using Targeting Rules with Custom Attributes. Any subscriber attributes should be set before @@ -122,6 +212,34 @@ suspend fun Purchases.awaitSyncAttributesAndOfferingsIfNeeded(): Offerings { } } + +/** + * Syncs subscriber attributes and then fetches the configured offerings for this user. This method is intended to + * be called when using Targeting Rules with Custom Attributes. Any subscriber attributes should be set before + * calling this method to ensure the returned offerings are applied with the latest subscriber attributes. + * + * This method is rate limited to 5 calls per minute. It will log a warning and return offerings cache when reached. + * + * Refer to [the guide](https://www.revenuecat.com/docs/tools/targeting) for more targeting information + * For more offerings information, see [getOfferings] + * + * Coroutine friendly version of [Purchases.syncAttributesAndOfferingsIfNeeded]. + * + * @throws [PurchasesException] with the first [PurchasesError] if there's an error syncing attributes + * or fetching offerings. + * @returns The [Offerings] fetched after syncing attributes. + */ +@JvmSynthetic +@Throws(PurchasesException::class) +suspend fun Purchases.awaitCancellableSyncAttributesAndOfferingsIfNeeded(): Offerings { + return suspendCancellableCoroutine { continuation -> + syncAttributesAndOfferingsIfNeededWith( + onSuccess = continuation::resume, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} + /** * Note: This method only works for the Amazon Appstore. There is no Google equivalent at this time. * Calling from a Google-configured app will always return AmazonLWAConsentStatus.UNAVAILABLE. @@ -147,3 +265,29 @@ suspend fun Purchases.getAmazonLWAConsentStatus(): AmazonLWAConsentStatus { ) } } + +/** + * Note: This method only works for the Amazon Appstore. There is no Google equivalent at this time. + * Calling from a Google-configured app will always return AmazonLWAConsentStatus.UNAVAILABLE. + * + * Get the Login with Amazon consent status for the current user. Used to implement one-click + * account creation using Quick Subscribe. + * + * For more information, check the documentation: + * https://developer.amazon.com/docs/in-app-purchasing/iap-quicksubscribe.html + * + * Coroutine friendly version of [Purchases.getAmazonLWAConsentStatus]. + * + * @throws [PurchasesException] with the first [PurchasesError] if there's an error getting the consent status + * @returns The AmazonLWAConsentStatus for the current user. + */ +@JvmSynthetic +@Throws(PurchasesException::class) +suspend fun Purchases.getCancellableAmazonLWAConsentStatus(): AmazonLWAConsentStatus { + return suspendCancellableCoroutine { continuation -> + getAmazonLWAConsentStatusWith( + onSuccess = continuation::resume, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt index 4f9753b186..bb2abb2987 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/CoroutinesExtensionsCommon.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreTransaction +import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @@ -30,6 +31,30 @@ suspend fun Purchases.awaitOfferings(): Offerings { } } +/** + * Fetch the configured offerings for this users. Offerings allows you to configure your in-app + * products via RevenueCat and greatly simplifies management. See + * [the guide](https://docs.revenuecat.com/offerings) for more info. + * + * Offerings will be fetched and cached on instantiation so that, by the time they are needed, + * your prices are loaded for your purchase flow. Time is money. + * + * Coroutine friendly version of [Purchases.getOfferings]. + * + * @throws [PurchasesException] with a [PurchasesError] if there's an error retrieving the offerings. + * @return The [Offerings] available to this user. + */ +@JvmSynthetic +@Throws(PurchasesException::class) +suspend fun Purchases.awaitCancellableOfferings(): Offerings { + return suspendCancellableCoroutine { continuation -> + getOfferingsWith( + onSuccess = continuation::resume, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} + /** * Initiate a purchase with the given [PurchaseParams]. * Initialized with an [Activity] either a [Package], [StoreProduct], or [SubscriptionOption]. @@ -64,6 +89,40 @@ suspend fun Purchases.awaitPurchase(purchaseParams: PurchaseParams): PurchaseRes } } +/** + * Initiate a purchase with the given [PurchaseParams]. + * Initialized with an [Activity] either a [Package], [StoreProduct], or [SubscriptionOption]. + * + * If a [Package] or [StoreProduct] is used to build the [PurchaseParams], the [StoreProduct.defaultOption] will + * be purchased. + * [StoreProduct.defaultOption] is selected via the following logic: + * - Filters out offers with "rc-ignore-offer" tag + * - Uses [SubscriptionOption] with the longest free trial or cheapest first phase + * - Falls back to use base plan + * + * @params [purchaseParams] The parameters configuring the purchase. See [PurchaseParams.Builder] for options. + * @throws [PurchasesTransactionException] with a [PurchasesTransactionException] if there's an error when purchasing + * and a userCancelled boolean that indicates if the user cancelled the purchase flow. + * @return The [StoreTransaction] for this purchase and the updated [CustomerInfo] for this user. + */ +@JvmSynthetic +@Throws(PurchasesTransactionException::class) +suspend fun Purchases.awaitCancellablePurchase(purchaseParams: PurchaseParams): PurchaseResult { + return suspendCancellableCoroutine { continuation -> + purchase( + purchaseParams = purchaseParams, + callback = purchaseCompletedCallback( + onSuccess = { storeTransaction, customerInfo -> + continuation.resume(PurchaseResult(storeTransaction, customerInfo)) + }, + onError = { purchasesError, userCancelled -> + continuation.resumeWithException(PurchasesTransactionException(purchasesError, userCancelled)) + }, + ), + ) + } +} + /** * Gets the StoreProduct(s) for the given list of product ids of type [type], or for all types if no type is specified. * @@ -94,6 +153,36 @@ suspend fun Purchases.awaitGetProducts( } } +/** + * Gets the StoreProduct(s) for the given list of product ids of type [type], or for all types if no type is specified. + * + * Coroutine friendly version of [Purchases.getProducts]. + * + * @param [productIds] List of productIds + * @param [type] A product type to filter by + * + * @throws [PurchasesException] with a [PurchasesError] if there's an error retrieving the products. + * @return A list of [StoreProduct] with the products that have been able to be fetched from the store successfully. + * Not found products will be ignored. + */ +@JvmSynthetic +@Throws(PurchasesTransactionException::class) +suspend fun Purchases.awaitCancellableGetProducts( + productIds: List, + type: ProductType? = null, +): List { + return suspendCancellableCoroutine { continuation -> + getProductsWith( + productIds, + type, + onGetStoreProducts = continuation::resume, + onError = { + continuation.resumeWithException(PurchasesException(it)) + }, + ) + } +} + /** * Restores purchases made with the current Play Store account for the current user. * This method will post all purchases associated with the current Play Store account to @@ -120,3 +209,30 @@ suspend fun Purchases.awaitRestore(): CustomerInfo { ) } } + +/** + * Restores purchases made with the current Play Store account for the current user. + * This method will post all purchases associated with the current Play Store account to + * RevenueCat and become associated with the current `appUserID`. If the receipt token is being + * used by an existing user, the current `appUserID` will be aliased together with the + * `appUserID` of the existing user. Going forward, either `appUserID` will be able to reference + * the same user. + * + * You shouldn't use this method if you have your own account system. In that case + * "restoration" is provided by your app passing the same `appUserId` used to purchase originally. + * + * Coroutine friendly version of [Purchases.restorePurchases]. + * + * @throws [PurchasesException] with a [PurchasesError] if there's an error login out the user. + * @return The [CustomerInfo] with the restored purchases. + */ +@JvmSynthetic +@Throws(PurchasesTransactionException::class) +suspend fun Purchases.awaitCancellableRestore(): CustomerInfo { + return suspendCancellableCoroutine { continuation -> + restorePurchasesWith( + onSuccess = { continuation.resume(it) }, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/HelperFunctions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/HelperFunctions.kt index 2a9a0f61d6..3e09e62d18 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/HelperFunctions.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/HelperFunctions.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.getCustomerInfoWith +import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -38,6 +39,13 @@ internal suspend fun shouldDisplayPaywall(shouldDisplayBlock: (CustomerInfo) -> return suspendCoroutine { continuation -> shouldDisplayPaywall(shouldDisplayBlock, continuation::resume) } } +/** + * Evaluates [shouldDisplayBlock] with the current CustomerInfo to determine if a paywall should be displayed. + */ +internal suspend fun shouldDisplayPaywallCancellable(shouldDisplayBlock: (CustomerInfo) -> Boolean): Boolean { + return suspendCancellableCoroutine { continuation -> shouldDisplayPaywall(shouldDisplayBlock, continuation::resume) } +} + /** * Evaluates [shouldDisplayBlock] with the current CustomerInfo to determine if a paywall should be displayed. */