State of in-app subscriptions report is here. Download now

Processing Android in-app purchases with the Google Play Billing Library

Vlad Guriev

Updated: October 9, 2023

17 min read

Content

612ce57059e6a3b3499e041d android tutorial 2 purchases processing min

This is the second article in our series about implementing in-app purchases on Android. In this part we’ll focus on Google Play purchase processing, as well as talk about paywalls and what elements they usually consist of. As you may know, Google Play Billing Library is a payment system that allows users to make in-app purchases within Android applications. It was first introduced in 2012 and is managed through the Google Play Store. Users can purchase digital content such as in-app items, subscriptions, or upgrades with a variety of payment methods. Your app accesses the Google Play server through an API exposed by the Google Play App installed on the user’s mobile device. The GP app processes all billing details between the app and the Google Play server. In this tutorial, you’ll learn how to configure such purchase processes.

Remember to check the rest of the articles in the series:

  1. Android in-app purchases, part 1: configuration and adding to the project
  2. Android in-app purchases, part 2: processing purchases with the Google Play Billing Library.
  3. Android in-app purchases, part 3: retrieving active purchases and subscription change.
  4. Android in-app purchases, part 4: error codes from the Billing Library and testing.
  5. Android in-app purchases, part 5: server-side purchase validation.

Designing a Google Play subscription screen

In the previous article, we created a wrapper class to work with the Billing Library:

import android.content.Context
import com.android.billingclient.api.*
class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }
   class Error(val responseCode: Int, val debugMessage: String)
   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()
   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "some_inapp")
       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }
   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }
   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }
           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }
   override fun onPurchasesUpdated(billingResult: BillingResult, purchaseList: MutableList<Purchase>?) {
       // here come callbacks about new purchases
   }
}

Now, we’ll proceed to the purchase implementation and improve our class. But first of all, let’s step aside and say a few words about paywalls.

Google Policy for mobile paywall screens

Any app that features in-app purchases has a paywall screen. There are Google policies that define the bare minimum of elements and instructional texts that must be present in such screens. Here’s a summary. In the paywall screen, you must be explicit about your subscription conditions, cost, and duration, as well as specify whether the subscription is necessary to use the app. You must also avoid forcing your users to perform any additional action to review the conditions. If you offer a free trial, make sure to let your users know how and when it will convert to a paid subscription, and how much they’ll have to pay for it. It’s generally a good idea to be transparent about everything, even about the ways to cancel your subscription. Remember, that any misleading or violation of the policies may result in removing your app from the store, as well as banning your account. But let’s get back to the basics.

Here, we’ll use a simplified paywall screen as an example:

android sample paywall
Android sample paywall

We have the following elements on the paywall screen:

  • A heading.
  • Buttons set to start the purchase process. These buttons guide the user about the subscription options’ general details, such as the title and the cost, which is displayed in the local currency.
  • Instructional text. 
  • A button to restore the previously made purchase. This element is a must for any app that features subscriptions or non-consumable purchases.

Tweaking the code to display product details

There are four products in our example:

  • Two auto-renewable subscriptions (“premium_sub_month” and “premium_sub_year”);
  • A product that can’t be bought twice, or a non-consumable product (“unlock_feature”);
  • A product that can be bought multiple times, or a consumable product (“coin_pack_large”).

To simplify the example, we’ll use an Activity which we’ll inject with the BillingClientWrapper created in the previous article, as well as a layout with a fixed number of purchase buttons.

class PaywallActivity: BaseActivity() {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       displayProducts() //to be declared below
   }
}

For convenience, we’ll add a map where the key is the product’s sku, and the value is the corresponding button on the screen.

private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
   mapOf(
       "premium_sub_month" to monthlySubButton,
       "premium_sub_year" to yearlySubButton,
       "coin_pack_large" to coinPackLargeButton,
       "unlock_feature" to unlockFeatureButton,
   )
}

Let’s declare a method to display the products in the UI. It will be based on the logic introduced in our previous tutorial.

private fun displayProducts() {
   billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
       override fun onSuccess(products: List<SkuDetails>>) {
           products.forEach { product ->
               purchaseButtonsMap[product.sku]?.apply {
                   text = "${product.description} for ${product.price}"
                   setOnClickListener {
                       billingClientWrapper.purchase(this@PaywallActivity, product) //will be declared below
                   }
               }
           }
       }

       override fun onFailure(error: BillingClientWrapper.Error) {
           //handle error
       }
   })
}

product.price  is an already formatted string that specifies the local currency for the account. It doesn’t need any extra formatting. All the other properties of the SkuDetails class object come fully localized as well.

Starting the in-app purchase process

To process the purchase, we have to invoke the launchBillingFlow() method from the app’s main thread.

We’ll add a purchase() method to the BillingClientWrapper to do that. 

fun purchase(activity: Activity, product: SkuDetails) {
   onConnected {
       activity.runOnUiThread {
           billingClient.launchBillingFlow(
               activity,
               BillingFlowParams.newBuilder().setSkuDetails(product).build()
           )
       }
   }
}

The launchBillingFlow() method has no callback. The response will return to the onPurchasesUpdated() method. Do you remember us declaring it in the previous article and then saving it for later on? Well, we’ll need it now.

The onPurchasesUpdated() method is called whenever there’s any outcome out of the user’s interaction with the purchase dialog. This can be a successful purchase, or a purchase cancellation caused by the user closing the dialog, in which case we’ll get the BillingResponseCode.USER_CANCELED code. Alternatively, it can be any of the other possible error messages.

In a similar fashion to the OnQueryProductsListener interface from the previous article, we’ll declare an OnPurchaseListener interface in the BillingClientWrapper class. Via that interface, we’ll receive either the purchase (a Purchase class object) or an error message declared by us in the previous guide. In the next one, we’ll discuss the case where the Purchase class object can be null even if the purchase was successful.

interface OnPurchaseListener {
   fun onPurchaseSuccess(purchase: Purchase?)
   fun onPurchaseFailure(error: Error)
}

var onPurchaseListener: OnPurchaseListener? = null

Next, we’ll implement it in the PaywallActivity:

class PaywallActivity: BaseActivity(), BillingClientWrapper.OnPurchaseListener {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
       mapOf(
           "premium_sub_month" to monthlySubButton,
           "premium_sub_year" to yearlySubButton,
           "coin_pack_large" to coinPackLargeButton,
           "unlock_feature" to unlockFeatureButton,
       )
   }

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       billingClientWrapper.onPurchaseListener = this

       displayProducts()
   }

   override fun onPurchaseSuccess(purchase: Purchase?) {
       //handle successful purchase
   }

   override fun onPurchaseFailure(error: BillingClientWrapper.Error) {
       //handle error or user cancelation
   }

   private fun displayProducts() {
       billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
           override fun onSuccess(products: List<SkuDetails>) {
               products.forEach { product ->
                   purchaseButtonsMap[product.sku]?.apply {
                       text = "${product.description} for ${product.price}"
                       setOnClickListener {
                           billingClientWrapper.purchase(this@PaywallActivity, product)
                       }
                   }
               }
           }

           override fun onFailure(error: BillingClientWrapper.Error) {
               //handle error
           }
       })
   }
}

10 ideas
to increase
paywall conversion

Get an ebook with insights
and advice from top experts

Let’s add some logic to onPurchaseUpdated():

override fun onPurchasesUpdated(
   billingResult: BillingResult,
   purchaseList: MutableList<Purchase>?
) {
   when (billingResult.responseCode) {
       BillingClient.BillingResponseCode.OK -> {
           if (purchaseList == null) {
               //to be discussed in the next article
               onPurchaseListener?.onPurchaseSuccess(null)
               return
           }

           purchaseList.forEach(::processPurchase) //to be declared below
       }
       else -> {
           //error occured or user canceled
           onPurchaseListener?.onPurchaseFailure(
               BillingClientWrapper.Error(
                   billingResult.responseCode,
                   billingResult.debugMessage
               )
           )
       }
   }
}

If purchaseList isn’t empty, we’ll first have to pick out the items whose purchaseState equals PurchaseState.PURCHASED, since there are pending purchases also. If that’s the case, the user flow ends here. According to the docs, we should then verify the purchase on our server. We’ll cover this in the articles to follow in this series. Once the server verification is completed, you must deliver the content and let Google know about it. Without the latter, the purchase will get automatically refunded in three days. It’s quite interesting that this policy is unique to Google Play — iOS doesn’t impose any similar ones. You have two ways of how to acknowledge delivering your content to the user:

If dealing with a consumable product, we have to invoke the consumeAsync() method instead. It acknowledges the purchase under the hood, while also making it possible to buy the product again. This can only be done with the Billing Library. For some reason, the Google Play Developer API doesn’t offer any way to do this on the backend side. It’s quite curious that unlike Google Play, both App Store and AppGallery configure the product as consumable via App Store Connect and AppGallery Connect, respectively. Though, such AppGallery products should be consumed in an explicit manner as well.

Let’s write the methods for acknowledge and consume, as well as two versions of the processPurchase() method to account for whether or not we’re running our own backend.

private fun acknowledgePurchase(
   purchase: Purchase,
   callback: AcknowledgePurchaseResponseListener
) {
   onConnected {
       billingClient.acknowledgePurchase(
           AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
               .build(),
           callback::onAcknowledgePurchaseResponse
       )
   }
}

private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
   onConnected {
       billingClient.consumeAsync(
           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
       ) { billingResult, purchaseToken ->
           callback.onConsumeResponse(billingResult, purchaseToken)
       }
   }
}

Without server verification:

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       onPurchaseListener?.onPurchaseSuccess(purchase)

       if (purchase.skus.firstOrNull() == "coin_pack_large") {
           //consuming our only consumable product
           consumePurchase(purchase) { billingResult, purchaseToken ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to consume again in onResume()
               }
           }
       } else if (!purchase.isAcknowledged) {
           acknowledgePurchase(purchase) { billingResult ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to acknowledge again in onResume()
               }
           }
       }
   }
}

With server verification:

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       api.verifyPurchase(purchase.purchaseToken) { error ->
           if (error != null) {
               onPurchaseListener?.onPurchaseSuccess(purchase)

               if (purchase.skus.firstOrNull() == "coin_pack_large") {
                   //consuming our only consumable product
                   billingClient.consumeAsync(
                       ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                           .build()
                   ) { billingResult, purchaseToken ->
                       if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                           //implement retry logic or try to consume again in onResume()
                       }
                   }
               }
           } else {
               //handle verification error
           }
       }
   }
}

In the articles to follow, we’ll cover server verification for purchases in more detail.

Of course, we could also implement the acknowledgement in the second example on the client side. However, if you can do something on the backend, then you should. If either the acknowledgement or the consumption process throws any errors, these cannot be ignored. In case none of these get executed within 3 days after the purchase receiving the PurchaseState.PURCHASED status, it will be canceled and refunded. So if it’s impossible to do on the backend and you keep getting the error after a number of attempts, there’s a reliable workaround. You’ll have to get the user’s current purchases via some lifecycle method, such as onStart() or onResume(), and keep trying, hoping that the user will open the app within 3 days while connected to the internet. 🙂

Therefore, the current version of the BillingClientWrapper class will look like this:

import android.app.Activity
import android.content.Context
import com.android.billingclient.api.*

class BillingClientWrapper(context: Context, private val api: Api) : PurchasesUpdatedListener {

   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }

   interface OnPurchaseListener {
       fun onPurchaseSuccess(purchase: Purchase?)
       fun onPurchaseFailure(error: Error)
   }

   var onPurchaseListener: OnPurchaseListener? = null

   class Error(val responseCode: Int, val debugMessage: String)

   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()

   fun purchase(activity: Activity, product: SkuDetails) {
       onConnected {
           activity.runOnUiThread {
               billingClient.launchBillingFlow(
                   activity,
                   BillingFlowParams.newBuilder().setSkuDetails(product).build()
               )
           }
       }
   }

   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "coin_pack_large", "unlock_feature")

       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }

   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }

   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }

           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }

   override fun onPurchasesUpdated(
       billingResult: BillingResult,
       purchaseList: MutableList<Purchase>?
   ) {
       when (billingResult.responseCode) {
           BillingClient.BillingResponseCode.OK -> {
               if (purchaseList == null) {
                   //to be discussed in the next article
                   onPurchaseListener?.onPurchaseSuccess(null)
                   return
               }

               purchaseList.forEach(::processPurchase)
           }
           else -> {
               //error occured or user canceled
               onPurchaseListener?.onPurchaseFailure(
                   BillingClientWrapper.Error(
                       billingResult.responseCode,
                       billingResult.debugMessage
                   )
               )
           }
       }
   }

   private fun processPurchase(purchase: Purchase) {
       if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
           api.verifyPurchase(purchase.purchaseToken) { error ->
               if (error != null) {
                   onPurchaseListener?.onPurchaseSuccess(purchase)

                   if (purchase.skus.firstOrNull() == "coin_pack_large") {
                       //consuming our only consumable product
                       billingClient.consumeAsync(
                           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                               .build()
                       ) { billingResult, purchaseToken ->
                           if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                               //implement retry logic or try to consume again in onResume()
                           }
                       }
                   }
               } else {
                   //handle verification error
               }
           }
       }
   }

   private fun acknowledgePurchase(
       purchase: Purchase,
       callback: AcknowledgePurchaseResponseListener
   ) {
       onConnected {
           billingClient.acknowledgePurchase(
               AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                   .build(),
               callback::onAcknowledgePurchaseResponse
           )
       }
   }

   private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
       onConnected {
           billingClient.consumeAsync(
               ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
           ) { billingResult, purchaseToken ->
               callback.onConsumeResponse(billingResult, purchaseToken)
           }
       }
   }
}

You may want to ask why the buttons are active for all products, no matter whether the user has already purchased these or not. Or, you may have some questions about what happens if you buy both subscriptions. Will the first subscription replace the second one, or will they co-exist? See the upcoming articles for all the answers 🙂

Unlock Industry Insights
Gain the edge with key insights from our State of in-app subscriptions in the US 2023 report. Essential for app professionals!
Get your free report now
Report in app US 2023 (book)

Recommended posts