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