Breaking changes, better Retry logic, and much more.
While the deadline for migrating to billing library v3.0 is around the corner (all updates to existing apps must use v3.0 or newer by November 1, 2021), Google recently rolled out v4.0. This latest version has a few minor breaking changes while keeping major future integrations in mind.
In this article, I’ll explain how the Signeasy team migrated our billing library to v4.0 and how we’re taking advantage of the new library to become more efficient.
Things to keep in mind before migrating to a new library:
1. Acknowledging the transaction is mandatory — If not acknowledged within 3 days of a purchase, the money will be refunded to the customer (starting v3.0).
2. Callbacks in background thread instead of UI thread — The billingClient method callbacks are called in the background thread. If you want to update any UI components, make sure you are calling it on the UI thread (starting v4.0).
Breaking changes:
1. Update billing library in your app/build.gradle file to 4.0.0.
implementation 'com.android.billingclient:billing:4.0.0'
2. QueryPurchases() is deprecated in favor of QueryPurchasesAsync()
Call QueryPurchasesAsync() method and implement PurchasesResponseListener callback to get the active purchases result asynchronously.
/**
* Fetch purchases
* (Active subscriptions & non-consumed in-app purchases)
*/
private fun queryActivePurchases() {
billingClient.queryPurchasesAsync(BillingClient.SkuType.SUBS, this)
billingClient.queryPurchasesAsync(BillingClient.SkuType.INAPP, this)
}
/**
* queryPurchasesAsync callback
*/
override fun onQueryPurchasesResponse(
billingClient: BillingResult,
purchases: MutableList<Purchase>
) {
if(billingClient.responseCode == BillingClient.BillingResponseCode.OK) {
// process purchases
}
}
3. Callbacks in background thread
As of v4.0, all billingClient method callbacks are called in the background thread. If you want to update any UI components, make sure you are calling them on the UI thread. This small code snippet may come in handy during implementation.
//Checks if the thread is running on the main thread.
fun isOnMainThread() = Looper.myLooper() == Looper.getMainLooper()
//This method ensure the code runs on main thread
fun ensureMainThread(callback: () -> Unit) {
if (isOnMainThread()) {
callback()
} else {
Handler(Looper.getMainLooper()).post(callback)
}
}
//Usage
override fun onBillingSetupFinished(billingResult: BillingResult) {
ensureMainThread {
//Make UI changes here
}
}
4. Subscription upgrade/downgrade
We used to set oldSku details in BillingFlowParams using the Builder class, as shown below.
private fun initiatePurchaseFlow(skuDetails: SkuDetails) {
val params = BillingFlowParams.newBuilder()
.setSkuDetails(skuDetails)
.setOldSku("oldSkuId", "oldSkuPurchaseToken")
.setReplaceSkusProrationMode(IMMEDIATE_WITH_TIME_PRORATION)
.build()
billingClient.launchBillingFlow(this, params)
}
This has been changed in billing library 4.0: the setOldSku and setReplaceSkusProrationMode methods have been removed from BillingFlowParams. These details should now be set using BillingFlowParams.SubscriptionUpdateParams
private fun initiatePurchaseFlow(skuDetails: SkuDetails) {
val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldSkuPurchaseToken("oldSkuPurchaseToken")
.setReplaceSkusProrationMode(IMMEDIATE_WITH_TIME_PRORATION)
.build()
val billingFlowParams = BillingFlowParams.newBuilder()
.setSkuDetails(skuDetails)
.setSubscriptionUpdateParams(updateParams)
.build()
billingClient.launchBillingFlow(this, billingFlowParams)
}
5. Other changes
- Consumable in-app products — In the future, users will be able to buy multiple in-app products (i.e. coins, credits) at a time. The Purchase.quantity and PurchaseHistoryRecord.quantity methods have been added in order to identify the quantity products being purchased.
- Subscriptions — Purchase.skus & PurchaseHistoryRecord.skus has been introduced to replace Purchase.sku & PurchaseHistoryRecord.sku.
Retry logic:
Processing the purchases may miss-out due to different reasons. The PurchasesUpdatedListener callback is not guaranteed to trigger in some cases, such as:
- Network issues during the purchase
- Pending transactions (offline payment)
- Purchase might have happened even before the user installed the app (outside of the app)
- User is using multiple devices with the same Play Store account
In these situations, we need to process any purchases that have not been finalized already and give the entitlement to the user. This is where queryPurchasesAsync() - which lists all active subscriptions and un-consumed in-app products - comes in handy. This would list us the active subscriptions & un-consumed in-app products. Call this method on Activity’s onResume(), send the receipt to the server if available, and process the purchase.
How do we determine if the purchase has already been processed? Sending the same purchase receipt again and again would choke the server. So what’s the solution here?
Purchase.isAcknowledged() to the rescue!
With billing library v3.0, Google made it mandatory to acknowledge a purchase. Once the purchase is made in the app, we send the purchase receipt to our server, at which point the server verifies and acknowledges the purchase.
Next time, when we call the queryPurchasesAsync() in the app, the purchase details in the isAcknowledged boolean are set to true. This is what determines whether the purchase has been processed. We, therefore, send the receipt to the server when the purchase is not acknowledged.
/**
* Fetch purchases
* (Active subscriptions & non-consumed in-app purchases)
*/
private fun queryActivePurchases() {
billingClient.queryPurchasesAsync(BillingClient.SkuType.SUBS, this)
billingClient.queryPurchasesAsync(BillingClient.SkuType.INAPP, this)
}
/**
* queryPurchasesAsync callback
*/
override fun onQueryPurchasesResponse(
billingClient: BillingResult,
purchases: MutableList<Purchase>
) {
if(billingClient.responseCode == BillingClient.BillingResponseCode.OK) {
// process purchases
retryProcessingPurchase(purchases)
}
}
/**
* Retry sending the purchase receipt to server
*/
fun retryProcessingPurchase(purchases: MutableList) {
//Get recent purchase from the list
val recentPurchaseTime = purchases.maxOfOrNull{it.purchaseTime}
val recentPurchase = purchases
.firstOrNull { it.purchaseTime == recentPurchaseTime }
?: return
//Check the purchase state
if (recentPurchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
if (!recentPurchase.isAcknowledged) {
//Send the receipt to server
}
}
}
Happy coding!