Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ISSUE-1765: Make suspend coroutine wrappers cancellable #1805

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<String>,
type: ProductType? = null,
): List<StoreProduct> {
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
Expand All @@ -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)) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
*/
Expand Down