Skip to content

Commit

Permalink
purchase coroutine (#1142)
Browse files Browse the repository at this point in the history
Adds a coroutine alternative to `purchase()`

---------

Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com>
  • Loading branch information
aboedo and tonidero authored Jul 21, 2023
1 parent a3d16be commit e2cab2a
Show file tree
Hide file tree
Showing 13 changed files with 338 additions and 130 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.revenuecat.apitester.java;

import com.revenuecat.purchases.PurchasesError;
import com.revenuecat.purchases.PurchasesErrorCode;
import com.revenuecat.purchases.PurchasesTransactionException;

final class PurchasesTransactionExceptionAPI {
static void check(final PurchasesTransactionException exception) {
final String underlyingErrorMessage = exception.getUnderlyingErrorMessage();
final String message = exception.getMessage();
final PurchasesErrorCode code = exception.getCode();
final PurchasesError error = exception.getError();
final boolean userCancelled = exception.getUserCancelled();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.ProductType
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.getOfferingsWith
import com.revenuecat.purchases.getProductsWith
import com.revenuecat.purchases.interfaces.GetStoreProductsCallback
Expand Down Expand Up @@ -123,8 +125,14 @@ private class PurchasesCommonAPI {

suspend fun checkCoroutines(
purchases: Purchases,
activity: Activity,
packageToPurchase: Package,
) {
val offerings: Offerings = purchases.awaitOfferings()

val purchasePackageBuilder: PurchaseParams.Builder = PurchaseParams.Builder(activity, packageToPurchase)
val (transaction, newCustomerInfo) = purchases.awaitPurchase(purchasePackageBuilder.build())
val purchaseResult: PurchaseResult = purchases.awaitPurchase(purchasePackageBuilder.build())
}

@Suppress("ForbiddenComment")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.revenuecat.apitester.kotlin

import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.PurchasesTransactionException

class PurchasesTransactionExceptionAPI {
fun check(exception: PurchasesTransactionException) {
val underlyingErrorMessage: String? = exception.underlyingErrorMessage
val message: String = exception.message
val code: PurchasesErrorCode = exception.code
val error: PurchasesError = exception.error
val userCancelled: Boolean = exception.userCancelled
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,29 @@ import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialContainerTransform
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesTransactionException
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.getCustomerInfoWith
import com.revenuecat.purchases.getOfferingsWith
import com.revenuecat.purchases.models.GoogleProrationMode
import com.revenuecat.purchases.models.GooglePurchasingData
import com.revenuecat.purchases.models.PurchasingData
import com.revenuecat.purchases.models.StoreProduct
import com.revenuecat.purchases.models.StoreTransaction
import com.revenuecat.purchases.models.SubscriptionOption
import com.revenuecat.purchases.purchaseWith
import com.revenuecat.purchases_sample.R
import com.revenuecat.purchases_sample.databinding.FragmentOfferingBinding
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

@SuppressWarnings("TooManyFunctions")
@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListener {

lateinit var binding: FragmentOfferingBinding
Expand All @@ -46,20 +47,6 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen
private lateinit var dataStoreUtils: DataStoreUtils
private var isPlayStore: Boolean = true

private val purchaseErrorCallback: (error: PurchasesError, userCancelled: Boolean) -> Unit =
{ error, userCancelled ->
toggleLoadingIndicator(false)
if (!userCancelled) {
showUserError(requireActivity(), error)
}
}

private val successfulPurchaseCallback: (purchase: StoreTransaction?, customerInfo: CustomerInfo) -> Unit =
{ storeTransaction, _ ->
toggleLoadingIndicator(false)
handleSuccessfulPurchase(storeTransaction?.orderId)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = MaterialContainerTransform().apply {
Expand Down Expand Up @@ -164,23 +151,35 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen
purchaseParamsBuilder.isPersonalizedPrice(isPersonalizedPrice)
}

Purchases.sharedInstance.purchaseWith(
purchaseParamsBuilder.build(),
purchaseErrorCallback,
successfulPurchaseCallback,
)
purchase(purchaseParamsBuilder.build())
}
}
} else {
if (isPersonalizedPrice) {
purchaseParamsBuilder.isPersonalizedPrice(isPersonalizedPrice)
}
purchase(purchaseParamsBuilder.build())
}
}

Purchases.sharedInstance.purchaseWith(
purchaseParamsBuilder.build(),
purchaseErrorCallback,
successfulPurchaseCallback,
)
private fun purchase(params: PurchaseParams) {
lifecycleScope.launch {
try {
val (storeTransaction, _) = Purchases.sharedInstance.awaitPurchase(params)
toggleLoadingIndicator(false)
handleSuccessfulPurchase(storeTransaction.orderId)
} catch (exception: PurchasesTransactionException) {
toggleLoadingIndicator(false)
if (!exception.userCancelled) {
showUserError(
requireActivity(),
PurchasesError(
underlyingErrorMessage = exception.underlyingErrorMessage,
code = exception.code,
),
)
}
}
}
}

Expand All @@ -194,6 +193,7 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen
false,
)
}

is GooglePurchasingData.InAppProduct -> {
ObserverModeBillingClient.purchase(
requireActivity(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.revenuecat.purchases

import com.revenuecat.purchases.models.StoreTransaction
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
Expand Down Expand Up @@ -31,3 +32,40 @@ suspend fun Purchases.awaitOfferings(): Offerings {
)
}
}

/**
* 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 [defaultOption] will be purchased.
* [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.
*
* @warning This function is marked as [ExperimentalPreviewRevenueCatPurchasesAPI] and may change in the future.
* Only available in Kotlin.
*/
@JvmSynthetic
@ExperimentalPreviewRevenueCatPurchasesAPI
@Throws(PurchasesTransactionException::class)
suspend fun Purchases.awaitPurchase(purchaseParams: PurchaseParams): PurchaseResult {
return suspendCoroutine { continuation ->
purchase(
purchaseParams = purchaseParams,
callback = purchaseCompletedCallback(
onSuccess = { storeTransaction, customerInfo ->
continuation.resume(PurchaseResult(storeTransaction, customerInfo))
},
onError = { purchasesError, userCancelled ->
continuation.resumeWithException(PurchasesTransactionException(purchasesError, userCancelled))
},
),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.revenuecat.purchases

import com.revenuecat.purchases.models.StoreTransaction

/**
* The result of a successful purchase operation. Used in coroutines.
*/
data class PurchaseResult(
/**
* The [StoreTransaction] for this purchase.
*/
val storeTransaction: StoreTransaction,

/**
* The updated [CustomerInfo] for this user after the purchase has been synced with RevenueCat's servers.
*/
val customerInfo: CustomerInfo,
)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.revenuecat.purchases

class PurchasesException(val error: PurchasesError) : Exception() {
open class PurchasesException(val error: PurchasesError) : Exception() {

val code: PurchasesErrorCode
get() = error.code
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.revenuecat.purchases

class PurchasesTransactionException(
purchasesError: PurchasesError,
val userCancelled: Boolean,
) : PurchasesException(purchasesError)
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

package com.revenuecat.purchases

import android.app.Activity
import android.app.Application
import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchaseHistoryRecord
import com.revenuecat.purchases.common.AppConfig
import com.revenuecat.purchases.common.Backend
Expand All @@ -22,11 +24,14 @@ import com.revenuecat.purchases.google.toStoreTransaction
import com.revenuecat.purchases.identity.IdentityManager
import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback
import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener
import com.revenuecat.purchases.models.GoogleProrationMode
import com.revenuecat.purchases.models.StoreProduct
import com.revenuecat.purchases.models.StoreTransaction
import com.revenuecat.purchases.models.SubscriptionOption
import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager
import com.revenuecat.purchases.utils.STUB_PRODUCT_IDENTIFIER
import com.revenuecat.purchases.utils.createMockOneTimeProductDetails
import com.revenuecat.purchases.utils.stubGooglePurchase
import com.revenuecat.purchases.utils.stubPurchaseHistoryRecord
import com.revenuecat.purchases.utils.stubStoreProduct
import com.revenuecat.purchases.utils.stubSubscriptionOption
Expand Down Expand Up @@ -71,6 +76,8 @@ internal open class BasePurchasesTest {
protected val appUserId = "fakeUserID"
protected lateinit var purchases: Purchases
protected val mockInfo = mockk<CustomerInfo>()
protected val mockActivity: Activity = mockk()
protected val subscriptionOptionId = "mock-base-plan-id:mock-offer-id"

@Before
fun setUp() {
Expand Down Expand Up @@ -259,12 +266,6 @@ internal open class BasePurchasesTest {
} just Runs
}

protected fun mockOfferingsManagerFetchOfferings(userId: String = appUserId) {
every {
mockOfferingsManager.fetchAndCacheOfferings(userId, any(), any(), any())
} just Runs
}

protected fun mockOfferingsManagerGetOfferings(errorGettingOfferings: PurchasesError? = null): Offerings? {
val offerings: Offerings = mockk()
every {
Expand Down Expand Up @@ -390,5 +391,57 @@ internal open class BasePurchasesTest {
} just Runs
}

protected fun getPurchaseParams(
purchaseable: Any,
oldProductId: String? = null,
isPersonalizedPrice: Boolean? = null,
googleProrationMode: GoogleProrationMode? = null
): PurchaseParams {
val builder = when (purchaseable) {
is SubscriptionOption -> PurchaseParams.Builder(mockActivity, purchaseable)
is Package -> PurchaseParams.Builder(mockActivity, purchaseable)
is StoreProduct -> PurchaseParams.Builder(mockActivity, purchaseable)
else -> null
}

oldProductId?.let {
builder!!.oldProductId(it)
}

isPersonalizedPrice?.let {
builder!!.isPersonalizedPrice(it)
}

googleProrationMode?.let {
builder!!.googleProrationMode(googleProrationMode)
}
return builder!!.build()
}

protected fun getMockedPurchaseList(
productId: String,
purchaseToken: String,
productType: ProductType,
offeringIdentifier: String? = null,
purchaseState: Int = Purchase.PurchaseState.PURCHASED,
acknowledged: Boolean = false,
subscriptionOptionId: String? = this.subscriptionOptionId
): List<StoreTransaction> {
val p = stubGooglePurchase(
productIds = listOf(productId),
purchaseToken = purchaseToken,
purchaseState = purchaseState,
acknowledged = acknowledged
)

return listOf(
p.toStoreTransaction(
productType,
offeringIdentifier,
if (productType == ProductType.SUBS) subscriptionOptionId else null
)
)
}

// endregion
}
Loading

0 comments on commit e2cab2a

Please sign in to comment.