Danial Goodwin Danial Goodwin

Over the course of a few days this week, for my side project, I implemented Android in-app billing v2 in Kotlin. It took about 5 hours to read through everything and implement it, but it should have taken less than an hour with the right sample code and walk-through.

This is that walk-through that I would have wanted to just quickly implement subscription services in my app.

Google, in general, has good documentation for their in-app billing, but their code sample was more helpful for me, even though it wasn't updated for the latest version of the library. There was a major version update with breaking changes that couldn't be copy-pasted into a current app.

There are about ten main pages of documentation for in-app billing, but to simply get started and running, you will only need one doc page and one sample code page:

  • https://developer.android.com/google/play/billing/billing_overview
  • https://github.com/googlesamples/android-play-billing/blob/master/TrivialDrive_v2/shared-module/src/main/java/com/example/billingmodule/billing/BillingManager.java

Or...

I can just provide the highlights and updated code that will actually work...

  • Product IDs must be unique and can NOT be reused. Product IDs must start with a lowercase letter or a number and must be composed of only lowercase letters (a-z), numbers (0-9), underscores (_), and periods (.). The product ID 'android.test' is unavailable for use, along with all product IDs that start with 'android.test'.
  • Subscriptions can NOT be unpublished
  • Subscription prices must be within a certain price range, US currently USD .99 - 400.00: https://support.google.com/googleplay/android-developer/table/3541286
  • The easiest way to test your purchase flow is to have testers defined in the Google Play Console > Settings > Developer account > Account details > License Testing > 'Gmail accounts with testing access'

Code:

  • Add implementation 'com.android.billingclient:billing:2.0.0' in your app's build.gradle file
  • Add <uses-permission android:name="com.android.vending.BILLING" /> in your app's AndroidManifest.xml file
  • Copy+paste this gist, then change the SKUs for your usage: https://gist.github.com/danialgoodwin/5bc709b19b776e707a827772401aaf96 (shown below)
package dev.goodwin
import android.app.Activity
import android.util.Log
import com.android.billingclient.api.*
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.FeatureType
import dev.goodwin.BillingManager.Companion.formatPeriod
/**
* When using this class:
* - Call `queryPurchases()` in your Activity's onResume() method
* - Call `query*SubscriptionSkuDetails()` when you want to show your in-app products
* - Call `startPurchaseFlow()` when one of your in-app products is clicked on
* - Call `destroy()` in your Activity's onDestroy() method
*
* Good example: https://github.com/googlesamples/android-play-billing/blob/master/TrivialDrive_v2/shared-module/src/main/java/com/example/billingmodule/billing/BillingManager.java
*
* Note: More security can be added by using 'developer payloads', but that is not used here.
*/
class BillingManager(
private val activity: Activity,
private val onEntitledPurchases: (List<Purchase>) -> Unit,
private val onPurchase: (Purchase) -> Unit
) {
private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
when (billingResult.responseCode) {
BillingResponseCode.OK -> {
purchases?.let {
for (purchase in purchases) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PURCHASED -> {
onPurchase(purchase)
if (!purchase.isAcknowledged) {
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
log("acknowledgePurchase(), billingResult=$billingResult")
}
}
}
Purchase.PurchaseState.PENDING -> {
// Here you can confirm to the user that they've started the pending
// purchase, and to complete it, they should follow instructions that
// are given to them. You can also choose to remind the user in the
// future to complete the purchase if you detect that it is still
// pending.
}
}
}
}
log("onPurchasesUpdated(), $purchases")
}
BillingResponseCode.USER_CANCELED -> log("onPurchasesUpdated() - user cancelled the purchase flow - skipping")
else -> log("onPurchasesUpdated() got unknown resultCode: ${billingResult.responseCode}")
}
}
private val billingClient: BillingClient = BillingClient.newBuilder(activity)
.enablePendingPurchases()
.setListener(purchasesUpdatedListener)
.build()
private var isBillingServiceConnected = false
init {
startServiceConnection {
queryPurchases()
}
}
fun queryPurchases() {
if (isSubscriptionPurchaseSupported()) {
val purchasesResult = billingClient.queryPurchases(BillingClient.SkuType.SUBS)
if (purchasesResult.responseCode == BillingResponseCode.OK) {
onEntitledPurchases(purchasesResult.purchasesList)
} else {
log("Error trying to query purchases: $purchasesResult")
}
} else {
onEntitledPurchases(emptyList())
}
}
fun queryBusinessSubscriptionSkuDetails(onSuccess: (List<SkuDetails>) -> Unit, onError: (code: Int, message: String) -> Unit) {
startServiceConnection {
querySubscriptionSkuDetails(listOf(Sku.BUSINESS_MONTHLY, Sku.BUSINESS_YEARLY), onSuccess, onError)
}
}
fun queryIndividualSubscriptionSkuDetails(onSuccess: (List<SkuDetails>) -> Unit, onError: (code: Int, message: String) -> Unit) {
startServiceConnection {
querySubscriptionSkuDetails(listOf(Sku.INDIVIDUAL_YEARLY), onSuccess, onError)
}
}
fun startPurchaseFlow(sku: SkuDetails) {
startServiceConnection {
val flowParams = BillingFlowParams.newBuilder().setSkuDetails(sku).build()
val billingResult = billingClient.launchBillingFlow(activity, flowParams)
log("startPurchaseFlow(...), billingResult=$billingResult")
}
}
fun destroy() {
log("destroy()")
if (billingClient.isReady) {
billingClient.endConnection()
}
}
private fun startServiceConnection(task: () -> Unit) {
if (isBillingServiceConnected) {
task()
} else {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
log("onBillingSetupFinished(...), billingResult=$billingResult")
if (billingResult.responseCode == BillingResponseCode.OK) {
isBillingServiceConnected = true
task()
}
}
override fun onBillingServiceDisconnected() {
log("onBillingServiceDisconnected()")
isBillingServiceConnected = false
// Try to restart the connection on the next request to
// Google Play by calling the startConnection() method.
}
})
}
}
private fun querySubscriptionSkuDetails(skus: List<String>, onSuccess: (List<SkuDetails>) -> Unit, onError: (code: Int, message: String) -> Unit) {
val params = SkuDetailsParams.newBuilder().setSkusList(skus).setType(BillingClient.SkuType.SUBS)
billingClient.querySkuDetailsAsync(params.build()) { billingResult, skuDetailsList ->
if (billingResult.responseCode == BillingResponseCode.OK && skuDetailsList != null) {
onSuccess(skuDetailsList)
} else {
onError(billingResult.responseCode, billingResult.debugMessage)
}
}
}
private fun isSubscriptionPurchaseSupported(): Boolean {
val response = billingClient.isFeatureSupported(FeatureType.SUBSCRIPTIONS)
if (response.responseCode != BillingResponseCode.OK) {
log("isSubscriptionPurchaseSupported(), not supported, error response: $response")
}
return response.responseCode == BillingResponseCode.OK
}
private fun log(message: String) {
Log.d("BillingManager", message)
}
companion object {
/**
* P1W equates to one week,
* P1M equates to one month,
* P3M equates to three months,
* P6M equates to six months,
* P1Y equates to one year
*/
fun formatPeriod(period: String, isIncludeSingularNumber: Boolean): String {
if (period.count() < 3) return ""
val isSingular = period[1] == '1'
val unit = when (period[2]) {
'W' -> if (isSingular) "week" else "weeks"
'M' -> if (isSingular) "month" else "months"
'Y' -> if (isSingular) "year" else "years"
else -> ""
}
return if (isSingular && !isIncludeSingularNumber) unit else "${period[1]} $unit"
}
}
/** The format of SKUs must start with number or lowercase letter and can contain only numbers (0-9),
* lowercase letters (a-z), underscores (_) & periods (.).*/
object Sku {
const val BUSINESS_MONTHLY = "business_monthly2" // Made a mistake with "business_monthly", accidentally make it yearly, so we can't use that SKU anymore
const val BUSINESS_YEARLY = "business_yearly"
const val INDIVIDUAL_YEARLY = "individual_yearly"
// Testing
// const val TEST_PURCHASED = "android.test.purchased"
// const val TEST_CANCELED = "android.test.canceled"
// const val TEST_UNAVAILABLE = "android.test.item_unavailable"
}
}
fun SkuDetails.displayPeriod() = formatPeriod(this.subscriptionPeriod, isIncludeSingularNumber = false)
fun SkuDetails.displayIntroductoryPeriod() = formatPeriod(this.introductoryPricePeriod, isIncludeSingularNumber = true)

From "0" to "it works on my machine" :p

Tags: