Skip to content
This repository has been archived by the owner on May 6, 2024. It is now read-only.

Commit

Permalink
feat: Upgrade the Google Play Billing Library (#1797)
Browse files Browse the repository at this point in the history
Incorporate the coroutines with the Billing Client

Fixes: LEARNER-9414
  • Loading branch information
HamzaIsrar12 committed Jul 29, 2023
1 parent a592a1e commit b1e9084
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 142 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,31 @@ import android.os.Handler
import android.os.Looper
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResult
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.SkuDetails
import com.android.billingclient.api.SkuDetailsParams
import com.android.billingclient.api.SkuDetailsResponseListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.edx.mobile.extenstion.encodeToString
import org.edx.mobile.logger.Logger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

/**
* The BillingProcessor implements all billing functionality for application.
Expand All @@ -31,15 +42,17 @@ import javax.inject.Singleton
* Inspiration: [https://github.com/android/play-billing-samples/blob/master/TrivialDriveKotlin/app/src/main/java/com/sample/android/trivialdrivesample/billing/BillingDataSource.kt]
* */
@Singleton
class BillingProcessor @Inject constructor(@ApplicationContext val context: Context) :
PurchasesUpdatedListener, BillingClientStateListener {
class BillingProcessor @Inject constructor(
@ApplicationContext val context: Context,
) : PurchasesUpdatedListener {

private val logger = Logger(TAG)

private val handler = Handler(Looper.getMainLooper())
private var connectionTryCount = 0

private lateinit var listener: BillingFlowListeners
private lateinit var billingClientStateListener: BillingClientStateListener

// Billing client, connection, cached data
private val billingClient: BillingClient = BillingClient.newBuilder(context)
Expand All @@ -51,30 +64,35 @@ class BillingProcessor @Inject constructor(@ApplicationContext val context: Cont
this.listener = listener
}

fun startConnection() {
if (!isConnected()) {
billingClient.startConnection(this)
}
private suspend fun isReadyOrConnect(): Boolean {
return billingClient.isReady || connect()
}

override fun onBillingSetupFinished(billingResult: BillingResult) {
logger.debug(
"BillingSetupFinished -> Response code: " + billingResult.responseCode.toString() +
" Debug message: " + billingResult.debugMessage
)
listener.onBillingSetupFinished(billingResult)
}
private suspend fun connect(): Boolean {
return suspendCoroutine { continuation ->
billingClientStateListener = object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
logger.debug("BillingSetupFinished -> $billingResult")
if (billingResult.responseCode == BillingResponseCode.OK) {
continuation.resume(true)
} else {
continuation.resume(false)
}
}

/**
* This is a pretty unusual occurrence. It happens primarily if the Google Play Store
* self-upgrades or is force closed.
*/
override fun onBillingServiceDisconnected() {
if (connectionTryCount > RECONNECT_MAX_COUNT) {
connectionTryCount++
retryBillingServiceConnectionWithExponentialBackoff()
} else {
listener.onBillingServiceDisconnected()
/**
* This is a pretty unusual occurrence. It happens primarily if the Google Play
* Store self-upgrades or is force closed.
*/
override fun onBillingServiceDisconnected() {
if (connectionTryCount > RECONNECT_MAX_COUNT) {
connectionTryCount++
retryBillingServiceConnectionWithExponentialBackoff()
}
continuation.resume(false)
}
}
billingClient.startConnection(billingClientStateListener)
}
}

Expand All @@ -88,11 +106,13 @@ class BillingProcessor @Inject constructor(@ApplicationContext val context: Cont
billingResult: BillingResult,
purchases: MutableList<Purchase>?
) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
if (!purchases[0].isAcknowledged) {
acknowledgePurchase(purchases[0])
if (billingResult.responseCode == BillingResponseCode.OK && purchases != null) {
if (!purchases.first().isAcknowledged) {
CoroutineScope(Dispatchers.IO).launch {
acknowledgePurchase(purchases.first())
}
} else {
listener.onPurchaseComplete(purchases[0])
listener.onPurchaseComplete(purchases.first())
}
} else {
listener.onPurchaseCancel(billingResult.responseCode, billingResult.debugMessage)
Expand All @@ -103,21 +123,19 @@ class BillingProcessor @Inject constructor(@ApplicationContext val context: Cont
* Called to purchase the new product. Query the product details and launch the purchase flow.
*
* @param activity active activity to launch our billing flow from
* @param productId SKU (Product ID) to be purchased
* @param productId Product Id to be purchased
* @param userId User Id of the purchaser
*/
fun purchaseItem(activity: Activity, productId: String, userId: Long) {
startConnection()
if (billingClient.isReady) {
querySyncDetails(productId) { billingResult, skuDetailsList ->
logger.debug(
"Getting Purchases -> Response code: " + billingResult.responseCode.toString() +
" Debug message: " + billingResult.debugMessage
)
if (skuDetailsList != null) {
launchBillingFlow(activity, skuDetailsList[0], userId)
}
suspend fun purchaseItem(activity: Activity, productId: String, userId: Long) {
if (isReadyOrConnect()) {
val response = querySyncDetails(productId)
logger.debug("Getting Purchases -> ${response.billingResult}")

response.productDetailsList?.first()?.let {
launchBillingFlow(activity, it, userId)
}
} else {
listener.onPurchaseCancel(BillingResponseCode.BILLING_UNAVAILABLE, "")
}
}

Expand All @@ -126,37 +144,42 @@ class BillingProcessor @Inject constructor(@ApplicationContext val context: Cont
* an Activity reference.
*
* @param activity active activity to launch our billing flow from
* @param skuDetail SKU (Product) to be purchased
* @param productDetails Product to be purchased
* @param userId User Id of the purchaser
*/
private fun launchBillingFlow(activity: Activity, skuDetail: SkuDetails, userId: Long) {
val billingFlowParamsBuilder = BillingFlowParams.newBuilder()
billingFlowParamsBuilder.setSkuDetails(skuDetail)
billingFlowParamsBuilder.setObfuscatedAccountId(userId.encodeToString())
billingClient.launchBillingFlow(
activity, billingFlowParamsBuilder.build()
private fun launchBillingFlow(
activity: Activity,
productDetails: ProductDetails,
userId: Long
) {
val productDetailsParamsList = listOf(
ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.build()
)

val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.setObfuscatedAccountId(userId.encodeToString())
.build()

billingClient.launchBillingFlow(activity, billingFlowParams)
}

/**
* Acknowledge new purchases are ones not yet acknowledged.
* @param purchase new purchase
*/
private fun acknowledgePurchase(purchase: Purchase) {
startConnection()
billingClient.acknowledgePurchase(
private suspend fun acknowledgePurchase(purchase: Purchase) {
isReadyOrConnect()
val billingResult = billingClient.acknowledgePurchase(
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
) { billingResult: BillingResult ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
logger.debug(
"acknowledgePurchase -> " +
"Response code: " + billingResult.responseCode.toString() +
" Debug message: " + billingResult.debugMessage
)
listener.onPurchaseComplete(purchase)
}
)
if (billingResult.responseCode == BillingResponseCode.OK) {
logger.debug("acknowledgePurchase -> $billingResult")
listener.onPurchaseComplete(purchase)
}
}

Expand All @@ -165,44 +188,49 @@ class BillingProcessor @Inject constructor(@ApplicationContext val context: Cont
*/
private fun retryBillingServiceConnectionWithExponentialBackoff() {
handler.postDelayed(
{ billingClient.startConnection(this@BillingProcessor) },
{ billingClient.startConnection(billingClientStateListener) },
RECONNECT_TIMER_START_MILLISECONDS
)
}

/**
* Calls the billing client functions to query sku details for in-app SKUs. SKU details are
* useful for displaying item names and price lists to the user, and are required to make a
* purchase.
* Calls the billing client functions to query product details for in-app products. Product
* details are useful for displaying item names and price lists to the user, and are required to
* make purchase.
*
* @param productId SKU of the product
* @param listener [SkuDetailsResponseListener]
* @param productId Id of the product
* @return Details of the product
* */
fun querySyncDetails(productId: String, listener: SkuDetailsResponseListener) {
startConnection()
billingClient.querySkuDetailsAsync(
SkuDetailsParams.newBuilder()
.setType(BillingClient.SkuType.INAPP)
.setSkusList(listOf(productId))
.build(), listener
)
suspend fun querySyncDetails(productId: String): ProductDetailsResult {
isReadyOrConnect()
val productDetails = QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(BillingClient.ProductType.INAPP)
.build()

return withContext(Dispatchers.IO) {
billingClient.queryProductDetails(
QueryProductDetailsParams
.newBuilder()
.setProductList(listOf(productDetails))
.build()
)
}
}

/**
* Method to query the Purchases async and returns purchases details for currently owned items
* Method to query the Purchases async and returns purchases for currently owned items
* bought within the app.
*
* @param listener callback interface to get the Purchases.
* */
fun queryPurchase(listener: PurchasesResponseListener) {
startConnection()
billingClient.queryPurchasesAsync(
BillingClient.SkuType.INAPP, listener
)
}

fun isConnected(): Boolean {
return billingClient.isReady
* @return List of purchases
**/
suspend fun queryPurchases(): List<Purchase> {
isReadyOrConnect()
return billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
).purchasesList
}

companion object {
Expand All @@ -213,15 +241,11 @@ class BillingProcessor @Inject constructor(@ApplicationContext val context: Cont
}

interface BillingFlowListeners {
fun onBillingServiceDisconnected() {}

fun onBillingSetupFinished(billingResult: BillingResult) {}

fun onPurchaseCancel(responseCode: Int, message: String) {}

fun onPurchaseComplete(purchase: Purchase) {}
}
}

fun SkuDetails.getPriceAmount(): Double =
this.priceAmountMicros.toDouble() / BillingProcessor.MICROS_TO_UNIT
fun ProductDetails.OneTimePurchaseOfferDetails.getPriceAmount(): Double =
this.priceAmountMicros.toDouble().div(BillingProcessor.MICROS_TO_UNIT)
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ object InAppPurchasesUtils {
): MutableList<IAPFlowData> {
purchases.forEach { purchase ->
auditCourses.find { course ->
purchase.skus[0].equals(course.productId)
purchase.products.first().equals(course.productId)
}?.apply {
this.purchaseToken = purchase.purchaseToken
this.flowType = flowType
Expand All @@ -46,15 +46,18 @@ object InAppPurchasesUtils {
ErrorMessage.EXECUTE_ORDER_CODE -> R.string.error_course_not_fullfilled
else -> R.string.general_error_message
}

HttpStatus.FORBIDDEN -> when (requestType) {
ErrorMessage.EXECUTE_ORDER_CODE -> R.string.error_course_not_fullfilled
else -> R.string.error_user_not_authenticated
}

HttpStatus.NOT_ACCEPTABLE -> R.string.error_course_already_paid
HttpStatus.CONFLICT -> when (requestType) {
ErrorMessage.EXECUTE_ORDER_CODE -> R.string.error_course_paid_and_verified
else -> R.string.general_error_message
}

else -> when (requestType) {
ErrorMessage.PAYMENT_SDK_CODE -> R.string.error_payment_not_processed
ErrorMessage.PRICE_CODE -> R.string.error_price_not_fetched
Expand Down
Loading

0 comments on commit b1e9084

Please sign in to comment.