Processing Android in-app purchases with the Google Play Billing Library
Updated: October 9, 2023
17 min read
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:
- Android in-app purchases, part 1: configuration and adding to the project
- Android in-app purchases, part 2: processing purchases with the Google Play Billing Library.
- Android in-app purchases, part 3: retrieving active purchases and subscription change.
- Android in-app purchases, part 4: error codes from the Billing Library and testing.
- 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:
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
}
})
}
}
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:
- Via acknowledgePurchase() on the client side;
- Via Product.Purchases.Acknowledge/Purchases.Subscriptions.Acknowledge on the backend side.
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 🙂