Google Play Billing is an integral part of Android. It's the most direct way for you to get paid for your hard work. This codelab shows you how to use the Google Play Billing Library in your project in a way that hides the nitty-gritty details from the rest of your app and engineering team.

What You Will Need

What You Will Build

You're going to add billing to a simple driving game app called Trivial Drive. The app is already built for you, so you'll just be adding the billing portion. As shown in figure 1, in this game the user drives and consumes gas. That's all. The user can buy gas; the user can buy a nicer car; the user can also buy either a monthly or a yearly subscription to a nice sunset background, which we call "gold status". Note that although this codelab mentions subscriptions, it doesn't cover how to implement them. To learn about subscriptions, refer to the Classy Taxi sample app.

To incorporate the Google Play Billing Library in your app (figure 2), you'll create the following:

  1. BillingRepository - a billing repository for your app that contains:
  1. BillingViewModel - a ViewModel through which the rest of your app communicates with the billing repository.

When finished, your app's architecture should look similar to figure 2:

Get the Code From Github

The code to get started is stored in a GitHub repository. You can clone the repository via the following command:

git clone https://github.com/googlecodelabs/play-billing-scalable-kotlin.git

Alternatively, you can download the repository as a ZIP file and extract it locally:

Download ZIP

Directory Structure

After you've cloned or unzipped from Github, you'll end up with the root directory play-billing-scalable-kotlin. The root directory contains the following folders:

/PATH/TO/YOUR/FOLDER/play-billing-scalable-kotlin/codelab-00
/PATH/TO/YOUR/FOLDER/play-billing-scalable-kotlin/codelab-01
/PATH/TO/YOUR/FOLDER/play-billing-scalable-kotlin/codelab-02
/PATH/TO/YOUR/FOLDER/play-billing-scalable-kotlin/codelab-03
/PATH/TO/YOUR/FOLDER/play-billing-scalable-kotlin/codelab-04

Each folder is an independent Android Studio project. The codelab-00 project contains the source that we'll use as our starting point. The optional codelab-NN projects contain the expected project state after each major section in this codelab. You can use these projects to check your work along the way.

Import the Project into Android Studio

Codelab-00 is the base app that doesn't contain a Google Play Billing implementation. Start Android Studio and import codelab-00 by choosing File -> New -> Import Project.... After Android Studio builds the project, attach a device via USB and run the app. You'll see a screen similar to figure 3.

The app consists of three class files: MainActivity, GameFragment, and MakePurchaseFragment.

Gradle

You need to add a Gradle dependency in order to add Google Play Billing to your app. Open the build.gradle file of the app module, and add the following:

dependencies {
   ...
   implementation "com.android.billingclient:billing:1.2"
   ...
}

Setting Up for the BillingRepository

A repository is a component that collects data from multiple sources and makes that data available to diverse clients, namely the rest of the app. The BillingRepository collects Purchase data from the Google Play Billing Library (i.e. BillingClient), your optional secure server, and the app's local cache. Then the repository makes the data available to the rest of the app. In this codelab you will not be dealing with the secure server portion.

Create a package called billingrepo, and add a Kotlin class file called BillingRepository.

Figure 4

Local Data Cache

At this point, before adding any methods to the BillingRepository, it's good to create the local billing database that the repo depends on. Trivial Drive sells gas, a premium car, and a "gold status" that lets you drive with a sunset background; so you should create a database to track the user's entitlement to these products.

Under billingrepo, add a package called localdb, and to it add the classes Entitlement, EntitlementsDao, and LocalBillingDb. While we're at it, let's also add the classes CachedPurchase, PurchaseDao, AugmentedSkuDetails, and AugmentedSkuDetailsDao. When finished, your project structure should look like (figure 5):

Figure 5

Descriptions and implementations of these classes are listed below. You can just copy and paste the code into your class files.

Entities

Entitlement

Entitlement is an abstract class that refers to products that you sell. It serves not only as a naming interface but also as a reminder that all product classes share certain functionality such as mayPurchase which tells clients of the BillingRepository whether a product should be purchased at a given moment.

package com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb

import androidx.room.Entity
import androidx.room.PrimaryKey

private const val FULL_TANK = 4
private const val EMPTY_TANK = 0
const val GAS_PURCHASE = 1

abstract class Entitlement {
   @PrimaryKey
   var id: Int = 1

/**
* This method tells clients whether a user __should__ buy a particular item at the moment. For
* example, if the gas tank is full the user should not be buying gas. This method is __not__
* a reflection on whether Google Play Billing can make a purchase.
*/
   abstract fun mayPurchase(): Boolean
}

@Entity(tableName = "premium_car")
data class PremiumCar(val entitled: Boolean) : Entitlement() {
   override fun mayPurchase(): Boolean = !entitled
}

@Entity(tableName = "gold_status")
data class GoldStatus(val entitled: Boolean) : Entitlement() {
   override fun mayPurchase(): Boolean = !entitled
}

@Entity(tableName = "gas_tank")
class GasTank(private var level: Int) : Entitlement() {

   fun getLevel() = level

   override fun mayPurchase(): Boolean = level < FULL_TANK

   fun needGas(): Boolean = level <= EMPTY_TANK

   fun decrement(by: Int = 1) {
       level -= by
   }
}

CachedPurchase

CachedPurchase takes the Purchase objects from Google Play's BillingClient and stores them in the local cache. Keeping a local copy makes certain operations easier, such as processing consumable products.

package com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb

import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters

import com.android.billingclient.api.Purchase

@Entity(tableName = "purchase_table")
@TypeConverters(PurchaseTypeConverter::class)
class CachedPurchase(val data: Purchase) {

   @PrimaryKey(autoGenerate = true)
   var id: Int = 0

   @Ignore
   val purchaseToken = data.purchaseToken
   @Ignore
   val sku = data.sku

   override fun equals(other: Any?): Boolean {
       return when (other) {
           is CachedPurchase -> data.equals(other.data)
           is Purchase -> data.equals(other)
           else -> false
       }
   }

   override fun hashCode(): Int {
       return data.hashCode()
   }
}

class PurchaseTypeConverter {
   @TypeConverter
   fun toString(purchase: Purchase): String = purchase.originalJson + '|' +
     purchase.signature

   @TypeConverter
   fun toPurchase(data: String): Purchase = data.split('|').let {
       Purchase(it[0], it[1])
   }
}

AugmentedSkuDetails

AugmentedSkuDetails is just a more client-friendly version of SkuDetails that gives the clients the additional info on whether to buy the relevant product at this moment.

package com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class AugmentedSkuDetails(val canPurchase: Boolean, /* Not in SkuDetails; it's the augmentation */                                  
                              @PrimaryKey val sku: String,
                              val type: String?,
                              val price: String?,
                              val title: String?,
                              val description: String?,
                              val originalJson: String?)

-Data Access Objects (DAO)

You need data access objects in order to access entity/table contents.

EntitlementsDao

package com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb

import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update

/**
* No update methods necessary since for each table there is ever expecting one
* row, hence why the primary key is hardcoded.
*/
@Dao
interface EntitlementsDao {

   @Insert(onConflict = OnConflictStrategy.REPLACE)
   fun insert(goldStatus: GoldStatus)

   @Update
   fun update(goldStatus: GoldStatus)

   @Query("SELECT * FROM gold_status LIMIT 1")
   fun getGoldStatus(): LiveData<GoldStatus>

   @Delete
   fun delete(goldStatus: GoldStatus)

   @Insert(onConflict = OnConflictStrategy.REPLACE)
   fun insert(premium: PremiumCar)

   @Update  fun update(premium: PremiumCar)

   @Query("SELECT * FROM premium_car LIMIT 1")
   fun getPremiumCar(): LiveData<PremiumCar>

   @Delete
   fun delete(premium: PremiumCar)

   @Insert(onConflict = OnConflictStrategy.REPLACE)
   fun insert(gasLevel: GasTank)

   @Update
   fun update(gasLevel: GasTank)

   @Query("SELECT * FROM gas_tank LIMIT 1")
   fun getGasTank(): LiveData<GasTank>

   @Delete
   fun delete(gasLevel: GasTank)

   /**
    * This is purely for convenience. The clients of this DAO don't have to
    * discriminate among [GasTank] vs [PremiumCar] vs [GoldStatus] but can
    * simply send in a list of [entitlements][Entitlement].
    */
   @Transaction
   fun insert(vararg entitlements: Entitlement) {
       entitlements.forEach {
           when (it) {
               is GasTank -> insert(it)
               is PremiumCar -> insert(it)
               is GoldStatus -> insert(it)
           }
       }
   }

   @Transaction
   fun update(vararg entitlements: Entitlement) {
       entitlements.forEach {
           when (it) {
               is GasTank -> update(it)
               is PremiumCar -> update(it)
               is GoldStatus -> update(it)
           }
       }
   }
}

PurchaseDao

package com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import com.android.billingclient.api.Purchase

@Dao
interface PurchaseDao {
   @Query("SELECT * FROM purchase_table")
   fun getPurchases(): List<CachedPurchase>

   @Insert
   fun insert(purchase: CachedPurchase)

   @Transaction
   fun insert(vararg purchases: Purchase) {
       purchases.forEach {
           insert(CachedPurchase(data = it))
       }
   }

   @Delete
   fun delete(vararg purchases: CachedPurchase)

   @Query("DELETE FROM purchase_table")
   fun deleteAll()
}

AugmentedSkuDetailsDao

package com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb

import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.SkuDetails

@Dao
interface AugmentedSkuDetailsDao {

   @Query("SELECT * FROM AugmentedSkuDetails WHERE type = '${BillingClient.SkuType.SUBS}'")
   fun getSubscriptionSkuDetails(): LiveData<List<AugmentedSkuDetails>>

   @Query("SELECT * FROM AugmentedSkuDetails WHERE type = '${BillingClient.SkuType.INAPP}'")
   fun getInappSkuDetails(): LiveData<List<AugmentedSkuDetails>>

   @Transaction
   fun insertOrUpdate(skuDetails: SkuDetails) = skuDetails.apply {
       val result = getById(sku)
       val canPurchase = if (result == null) true else result.canPurchase
       val originalJson = toString().substring("SkuDetails: ".length)
       val skuDetails = AugmentedSkuDetails(canPurchase, sku, type, price, title, description, originalJson)
       insert(skuDetails)
   }

   @Transaction
   fun insertOrUpdate(sku: String, canPurchase: Boolean) {
       val result = getById(sku)
       if (result != null) {
           update(sku, canPurchase)
       } else {
           insert(AugmentedSkuDetails(canPurchase, sku, null, null, null, null, null))
       }
   }

   @Query("SELECT * FROM AugmentedSkuDetails WHERE sku = :sku")
   fun getById(sku: String): AugmentedSkuDetails

   @Insert(onConflict = OnConflictStrategy.REPLACE)
   fun insert(augmentedSkuDetails: AugmentedSkuDetails)

   @Query("UPDATE AugmentedSkuDetails SET canPurchase = :canPurchase WHERE sku = :sku")
   fun update(sku: String, canPurchase: Boolean)
}

Database

LocalBillingDb

package com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [CachedPurchase::class, GasTank::class, PremiumCar::class, GoldStatus::class, AugmentedSkuDetails::class],
       version = 1, exportSchema = false)
abstract class LocalBillingDb : RoomDatabase() {
   abstract fun purchaseDao(): PurchaseDao
   abstract fun entitlementsDao(): EntitlementsDao
   abstract fun skuDetailsDao(): AugmentedSkuDetailsDao

   companion object {
       @Volatile
       private var INSTANCE: LocalBillingDb? = null

       fun getInstance(context: Context): LocalBillingDb = INSTANCE?: synchronized(this) {
           INSTANCE ?: Room.databaseBuilder(
                   context.applicationContext,
                   LocalBillingDb::class.java,
                   "purchase_db")
                   .fallbackToDestructiveMigration()//remote sources more reliable
                   .build().also { INSTANCE=it }
       }
   }
}

Before populating the BillingRepository class, build and run your app once more to make sure everything works as intended.

The BillingRepository will handle absolutely everything billing-related so that the rest of the app doesn't have to. Therefore, its constructor needs to accept an instance of the Application context. Additionally, it needs to implement all billing-related interfaces. Here's the class signature you should use:

class BillingRepository private constructor(private val application: Application) :
       PurchasesUpdatedListener, BillingClientStateListener,
       ConsumeResponseListener, SkuDetailsResponseListener {

}

Android Studio notifies you about unimplemented abstract methods, so click on the class and press Alt+Enter to implement all the methods. The class should now look like this:

class BillingRepository private constructor(private val application: Application) :
       PurchasesUpdatedListener, BillingClientStateListener,
       ConsumeResponseListener, SkuDetailsResponseListener {
   /**
    * Implement this method to get notifications for purchases updates. Both
    * purchases initiated by your app and the ones initiated by Play Store
    * will be reported here.
    *
    * @param responseCode Response code of the update.
    * @param purchases List of updated purchases if present.
    */
   override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) {
       TODO("not implemented")
   //To change body of created functions use File | Settings | File Templates.
   }

   /**
    * Called to notify that connection to billing service was lost
    *
    *
    * Note: This does not remove billing service connection itself - this
    * binding to the service
    * will remain active, and you will receive a call to 
    * [.onBillingSetupFinished] when billing
    * service is next running and setup is complete.
    */
   override fun onBillingServiceDisconnected() {
       TODO("not implemented") 
//To change body of created functions use File | Settings | File Templates.
   }

   /**
    * Called to notify that setup is complete.
    *
    * @param responseCode The response code from [BillingResponse] which returns the status of
    * the setup process.
    */
   override fun onBillingSetupFinished(responseCode: Int) {
       TODO("not implemented") 
//To change body of created functions use File | Settings | File Templates.
   }

   /**
    * Called to notify that a consume operation has finished.
    *
    * @param responseCode The response code from [BillingResponse] set to report the result of
    * consume operation.
    * @param purchaseToken The purchase token that was (or was to be) consumed.
    */
   override fun onConsumeResponse(responseCode: Int, purchaseToken: String?) {
       TODO("not implemented") 
//To change body of created functions use File | Settings | File Templates.
   }

   /**
    * Called to notify that a fetch SKU details operation has finished.
    *
    * @param responseCode Response code of the update.
    * @param skuDetailsList List of SKU details.
    */
   override fun onSkuDetailsResponse(responseCode: Int, skuDetailsList: MutableList<SkuDetails>?) {
       TODO("not implemented") 
//To change body of created functions use File | Settings | File Templates.
   }
}

You'll need to implement these methods, but first we need to do some preparatory housekeeping.

Preparatory Housekeeping

Retry Policy

Whenever you are communicating with remote services, you should have a retry policy in place. Add the following to the BillingRepository class.

/**
* This private object class shows an example retry policies. You may choose to replace it with
* your own policies.
*/
private object RetryPolicies {
   private val maxRetry = 5
   private var retryCounter = AtomicInteger(1)
   private val baseDelayMillis = 500
   private val taskDelay = 2000L

   fun resetConnectionRetryPolicyCounter() {
       retryCounter.set(1)
   }

   /**
* This works because it actually only makes one call. Then it waits for success or failure.
* onSuccess it makes no more calls and resets the retryCounter to 1. onFailure another
* call is made, until too many failures cause retryCounter to reach maxRetry and the
* policy stops trying. This is a safe algorithm: the initial calls to
* connectToPlayBillingService from instantiateAndConnectToPlayBillingService is always
* independent of the RetryPolicies. And so the Retry Policy exists only to help and never
* to hurt.
*/

   fun connectionRetryPolicy(block: () -> Unit) {
       Log.d(LOG_TAG, "connectionRetryPolicy")
       val scope = CoroutineScope(Job() + Dispatchers.Main)
       scope.launch {
           val counter = retryCounter.getAndIncrement()
           if (counter < maxRetry) {
               val waitTime: Long = (2f.pow(counter) * baseDelayMillis).toLong()
               delay(waitTime)
               block()
           }
       }
   }

   /**
    * All this is doing is check that billingClient is connected and if it's
    * not, request connection, wait x number of seconds and then proceed with
    * the actual task.
    */
   fun taskExecutionRetryPolicy(billingClient: BillingClient, listener: BillingRepository, task: () -> Unit) {
       val scope = CoroutineScope(Job() + Dispatchers.Main)
       scope.launch {
           if (!billingClient.isReady) {
               Log.d(LOG_TAG, "taskExecutionRetryPolicy billing not ready")
               billingClient.startConnection(listener)
               delay(taskDelay)
           }
           task()
       }
   }
}

Slow Down on the Server Calls

The BillingRepository should not make calls to your server too often, as they're costly. Instead, calls should be made at intervals. For example, this codelab uses a two hour interval. You can set this interval to reflect how often you expect users to purchase items. On the one hand, users should have access across all devices to purchases they've made. On the other hand, you don't want to be paying too much to hosting companies because the secure server is being hit too often just to check for new purchases. Add the following block to the BillingRepository to enforce the interval.

/**
* This is the throttling valve. It is used to modulate how often calls are
* made to the secure server in order to save money.
*/
private object Throttle {
   private val DEAD_BAND = 7200000//2*60*60*1000: two hours wait
   private val PREFS_NAME = "BillingRepository.Throttle"
   private val KEY = "lastInvocationTime"

   fun isLastInvocationTimeStale(context: Context): Boolean {
       val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
       val lastInvocationTime = sharedPrefs.getLong(KEY, 0)
       return lastInvocationTime + DEAD_BAND < Date().time
   }

   fun refreshLastInvocationTime(context: Context) {
       val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
       with(sharedPrefs.edit()) {
           putLong(KEY, Date().time)
           apply()
       }
   }
}
package com.example.playbilling.trivialdrive.kotlin.billingrepo

import com.android.billingclient.api.Purchase

class BillingWebservice {
   fun getPurchases(): Any {
       return Any()//TODO("not implemented")
   }
   fun updateServer(purchases: Set<Purchase>) {
       //TODO("not implemented")
   }
   fun onComsumeResponse(purchaseToken: String?, responseCode: Int) {
       //TODO("not implemented")
   }
   companion object {
       fun create(): BillingWebservice {
           //TODO("not implemented")
           return BillingWebservice()
       }
   }
}

The SKUs

For the final piece of preparation, we need to store the SKU IDs somewhere, as we'll need them for getting SkuDetails from Google Play. Add this snippet to the bottom of BillingRepository.

/**
* [INAPP_SKUS], [SUBS_SKUS], [CONSUMABLE_SKUS]:
*
* Where you define these lists is quite truly up to you. If you don't need
* customization, then it makes since to define and hardcode them here, as
* I am doing. Keep simple things simple. But there are use cases where you may
* need customization:
*
* - If you don't want to update your APK (or Bundle) each time you change your
*   SKUs, then you may want to load these lists from your secure server.
*
* - If your design is such that users can buy different items from different
*   Activities or Fragments, then you may want to define a list for each of
*   those subsets. I only have two subsets: INAPP_SKUS and SUBS_SKUS
*/

private object GameSku {
   val GAS = "gas"
   val PREMIUM_CAR = "premium_car"
   val GOLD_MONTHLY = "gold_monthly"
   val GOLD_YEARLY = "gold_yearly"

   val INAPP_SKUS = listOf(GAS, PREMIUM_CAR)
   val SUBS_SKUS = listOf(GOLD_MONTHLY, GOLD_YEARLY)
   val CONSUMABLE_SKUS = listOf(GAS)
   //coincidence that there only gold_status is a sub
   val GOLD_STATUS_SKUS = SUBS_SKUS
}

One is Enough

You probably noticed that the constructor of the BillingRepository is private. That's because Google Play Billing is an application-level service, which means we need only a single instance for the life of the app. Here's the getInstance method:

companion object {
   private const val LOG_TAG = "BillingRepository"

   @Volatile
   private var INSTANCE: BillingRepository? = null

   fun getInstance(application: Application): BillingRepository =
           INSTANCE ?: synchronized(this) {
               INSTANCE
                       ?: BillingRepository(application)
                               .also { INSTANCE = it }
           }
}

Fields and State Properties

We need to bring in the data sources and the entitlements. The data sources are Google Play's BillingClient, the BillingWebservice, and the LocalBillingDb. The entitlements are GasTank, PremiumCar, and GoldStatus. We also need AugmentedSkuDetails so clients know what's for sale.

lateinit private var playStoreBillingClient: BillingClient

lateinit private var secureServerBillingClient: BillingWebservice

lateinit private var localCacheBillingClient: LocalBillingDb

val subsSkuDetailsListLiveData: LiveData<List<AugmentedSkuDetails>> by lazy {
   if (::localCacheBillingClient.isInitialized == false) {
       localCacheBillingClient = LocalBillingDb.getInstance(application)
   }
   localCacheBillingClient.skuDetailsDao().getSubscriptionSkuDetails()
}

val inappSkuDetailsListLiveData: LiveData<List<AugmentedSkuDetails>> by lazy {
   if (::localCacheBillingClient.isInitialized == false) {
       localCacheBillingClient = LocalBillingDb.getInstance(application)
   }
   localCacheBillingClient.skuDetailsDao().getInappSkuDetails()
}

val gasTankLiveData: LiveData<GasTank> by lazy {
   if (::localCacheBillingClient.isInitialized == false) {
       localCacheBillingClient = LocalBillingDb.getInstance(application)
   }
   localCacheBillingClient.entitlementsDao().getGasTank()
}

val premiumCarLiveData: LiveData<PremiumCar> by lazy {
   if (::localCacheBillingClient.isInitialized == false) {
       localCacheBillingClient = LocalBillingDb.getInstance(application)
   }
   localCacheBillingClient.entitlementsDao().getPremiumCar()
}

val goldStatusLiveData: LiveData<GoldStatus> by lazy {
   if (::localCacheBillingClient.isInitialized == false) {
       localCacheBillingClient = LocalBillingDb.getInstance(application)
   }
   localCacheBillingClient.entitlementsDao().getGoldStatus()
}

Establish Connection

Once a client gets an instance of the BillingRepository, they'll want to establish connections with the data sources so they can get to business, i.e. let users access what they already own or buy additional items.

fun startDataSourceConnections() {
   Log.d(LOG_TAG, "startDataSourceConnections")
   instantiateAndConnectToPlayBillingService()
   secureServerBillingClient = BillingWebservice.create()
   localCacheBillingClient = LocalBillingDb.getInstance(application)
}

fun endDataSourceConnections() {
   playStoreBillingClient.endConnection()
   // normally you don't worry about closing a DB connection unless you have
   //more than one open. so no need to call 'localCacheBillingClient.close()'
   Log.d(LOG_TAG, "startDataSourceConnections")
}

private fun instantiateAndConnectToPlayBillingService() {
   playStoreBillingClient = BillingClient.newBuilder(application.applicationContext)
           .setListener(this).build()
   connectToPlayBillingService()
}

private fun connectToPlayBillingService(): Boolean {
   Log.d(LOG_TAG, "connectToPlayBillingService")
   if (!playStoreBillingClient.isReady) {
       playStoreBillingClient.startConnection(this)
       return true
   }
   return false
}

The override Methods

Now we can turn our attention to the Google Play Billing methods. First we'll tackle onBillingSetupFinished, which leads into querySkuDetailsAsync and onSkuDetailsResponse. Then we'll introduce queryPurchasesAsync along with its subroutines. After that we'll introduce launchBillingFlow, which permits users to buy items. Then we'll segue to onPurchasesUpdated and then to onConsumeResponse. Finally, we'll implement onBillingServiceDisconnected. We'll introduce other incidental minor methods along the way. The general architecture is shown in figure 6.

onBillingSetupFinished

Here the connection has been successfully established and Google Play's BillingClient is ready to receive calls. Next, we need to get the relevant SkuDetails objects in case user wants to buy something and then call queryPurchasesAsync in case user already bought something:

override fun onBillingSetupFinished(responseCode: Int) {
   when (responseCode) {
       BillingClient.BillingResponse.OK -> {
           Log.d(LOG_TAG, "onBillingSetupFinished successfully")
           resetConnectionRetryPolicyCounter()//for retry policy
           querySkuDetailsAsync(BillingClient.SkuType.INAPP, GameSku.INAPP_SKUS)
           querySkuDetailsAsync(BillingClient.SkuType.SUBS, GameSku.SUBS_SKUS)
           queryPurchasesAsync()
       }
       BillingClient.BillingResponse.BILLING_UNAVAILABLE -> {
           //Some apps may choose to make decisions based on this knowledge.
           Log.d(LOG_TAG, "onBillingSetupFinished but billing is not available on this device")
       }
       else -> {
           //do nothing. Someone else will connect it through retry policy.
           //May choose to send to server though
           Log.d(LOG_TAG, "onBillingSetupFinished with failure response code: $responseCode")
       }
   }
}

Two calls are made to querySkuDetailsAsync because Google Play Billing requires that SKU types for INAPP and SUBS be specified separately.

Here's the code for querySkuDetailsAsync:

private fun querySkuDetailsAsync(@BillingClient.SkuType skuType: String, skuList: List<String>) {
   val params = SkuDetailsParams.newBuilder()
   params.setSkusList(skuList).setType(skuType)
   taskExecutionRetryPolicy(playStoreBillingClient, this) {
       Log.d(LOG_TAG, "querySkuDetailsAsync for $skuType")
       playStoreBillingClient.querySkuDetailsAsync(params.build(), this)
   }
}

When the callback onSkuDetailsResponse is called, we send the data to the local cache, at which point the magic of LiveData takes over, and the clients are automatically updated on demand.

override fun onSkuDetailsResponse(responseCode: Int, skuDetailsList: MutableList<SkuDetails>?) {
   if (responseCode != BillingClient.BillingResponse.OK) {
       Log.w(LOG_TAG, "SkuDetails query failed with response: $responseCode")
   } else {
       Log.d(LOG_TAG, "SkuDetails query responded with success. List: $skuDetailsList")
   }

   if (skuDetailsList.orEmpty().isNotEmpty()) {
       val scope = CoroutineScope(Job() + Dispatchers.IO)
       scope.launch {
           skuDetailsList?.forEach { localCacheBillingClient.skuDetailsDao().insertOrUpdate(it) }
       }
   }
}

queryPurchasesAsync

queryPurchasesAsync is the method your app calls to get the list of Purchases that the user already owns from Play and from your secure server. The method grabs purchase data from both Google Play's BillingClient and your secure server and then merges the data into the local cache (i.e. LocalBillingDb). Once the purchases have been converted to entitlements and the local cache updated, all the entitlements' LiveData will update their observers so that the client sees everything the user owns.

fun queryPurchasesAsync() {
   fun task() {
       Log.d(LOG_TAG, "queryPurchasesAsync called")
       val purchasesResult = HashSet<Purchase>()
       var result = playStoreBillingClient.queryPurchases(BillingClient.SkuType.INAPP)
       Log.d(LOG_TAG, "queryPurchasesAsync INAPP results: ${result?.purchasesList}")
       result?.purchasesList?.apply { purchasesResult.addAll(this) }
       if (isSubscriptionSupported()) {
           result = playStoreBillingClient.queryPurchases(BillingClient.SkuType.SUBS)
           result?.purchasesList?.apply { purchasesResult.addAll(this) }
           Log.d(LOG_TAG, "queryPurchasesAsync SUBS results: ${result?.purchasesList}")
       }
       processPurchases(purchasesResult)
   }
   taskExecutionRetryPolicy(playStoreBillingClient, this) { task() }
}

queryPurchasesAsync has a number of subroutines:

private fun processPurchases(purchasesResult: Set<Purchase>) = CoroutineScope(Job() + Dispatchers.IO).launch {
   val cachedPurchases = localCacheBillingClient.purchaseDao().getPurchases()
   val newBatch = HashSet<Purchase>(purchasesResult.size)
   purchasesResult.forEach { purchase ->
       if (isSignatureValid(purchase) && !cachedPurchases.any { it.data == purchase }) {//todo !cachedPurchases.contains(purchase)
           newBatch.add(purchase)
       }
   }

   if (newBatch.isNotEmpty()) {
       sendPurchasesToServer(newBatch)
       // We still care about purchasesResult in case a old purchase has not
       // yet been consumed.
       saveToLocalDatabase(newBatch, purchasesResult)
       //consumeAsync(purchasesResult): do this inside saveToLocalDatabase to
       // avoid race condition
   } else if (isLastInvocationTimeStale(application)) {
       handleConsumablePurchasesAsync(purchasesResult)
       queryPurchasesFromSecureServer()
   }
}
private fun isSubscriptionSupported(): Boolean {
   val responseCode = playStoreBillingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS)
   if (responseCode != BillingClient.BillingResponse.OK) {
       Log.w(LOG_TAG, "isSubscriptionSupported() got an error response: $responseCode")
   }
   return responseCode == BillingClient.BillingResponse.OK
}

private fun isSignatureValid(purchase: Purchase): Boolean {
   return Security.verifyPurchase(Security.BASE_64_ENCODED_PUBLIC_KEY, purchase.originalJson, purchase.signature)
}
private fun sendPurchasesToServer(purchases: Set<Purchase>) {
  //not implemented here
}

private fun queryPurchasesFromSecureServer() {
   fun getPurchasesFromSecureServerToLocalDB() {//closure
       //do the actual work of getting the purchases from server
   }
   getPurchasesFromSecureServerToLocalDB()

   refreshLastInvocationTime(application)
}

saveToLocalDatabase and handleConsumablePurchasesAsync both put purchase and entitlement data into the local cache. There are two versions of saveToLocalDatabase: one handles consumables, while the other handles everything else.

private fun saveToLocalDatabase(newBatch: Set<Purchase>, allPurchases: Set<Purchase>) {
   val scope = CoroutineScope(Job() + Dispatchers.IO)
   scope.launch {
       newBatch.forEach { purchase ->
           when (purchase.sku) {
               GameSku.PREMIUM_CAR -> {
                   val premiumCar = PremiumCar(true)
                   insert(premiumCar)
                   localCacheBillingClient.skuDetailsDao().insertOrUpdate(purchase.sku, premiumCar.mayPurchase())
               }
               GameSku.GOLD_MONTHLY, GameSku.GOLD_YEARLY -> {
                   val goldStatus = GoldStatus(true)
                   insert(goldStatus)
                   localCacheBillingClient.skuDetailsDao().insertOrUpdate(purchase.sku, goldStatus.mayPurchase())
                   GOLD_STATUS_SKUS.forEach { otherSku ->
                       if (otherSku != purchase.sku) {
                           localCacheBillingClient.skuDetailsDao().insertOrUpdate(otherSku, !goldStatus.mayPurchase())
                       }
                   }
               }
           }
       }
       localCacheBillingClient.purchaseDao().insert(*newBatch.toTypedArray())
       handleConsumablePurchasesAsync(allPurchases)
   }
}

private fun saveToLocalDatabase(purchaseToken: String) {
   val scope = CoroutineScope(Job() + Dispatchers.IO)
   scope.launch {
       val cachedPurchases = localCacheBillingClient.purchaseDao().getPurchases()
       val match = cachedPurchases.find { it.purchaseToken == purchaseToken }
       if (match?.sku == GameSku.GAS) {
           updateGasTank(GasTank(GAS_PURCHASE))
           localCacheBillingClient.purchaseDao().delete(match)
       }
   }
}

private fun handleConsumablePurchasesAsync(purchases: Set<Purchase>) {
   purchases.forEach {
       if (GameSku.CONSUMABLE_SKUS.contains(it.sku)) {
           playStoreBillingClient.consumeAsync(it.purchaseToken, this@BillingRepository)
           //tell your server:
           Log.i(LOG_TAG, "handleConsumablePurchasesAsync: asked Play Billing to consume sku = ${it.sku}")
       }
   }
}

@WorkerThread
suspend fun updateGasTank(gas: GasTank) = withContext(Dispatchers.IO) {
   Log.d(LOG_TAG, "updateGasTank")
   var update: GasTank = gas
   gasTankLiveData.value?.apply {
       synchronized(this) {
           if (this != gas) {//new purchase
               update = GasTank(getLevel() + gas.getLevel())
           }
           Log.d(LOG_TAG, "New purchase level is ${gas.getLevel()}; existing level is ${getLevel()}; so the final result is ${update.getLevel()}")
           localCacheBillingClient.entitlementsDao().update(update)
       }
   }
   if (gasTankLiveData.value == null) {
       localCacheBillingClient.entitlementsDao().insert(update)
       Log.d(LOG_TAG, "No we just added from null gas with level: ${gas.getLevel()}")
   }
   localCacheBillingClient.skuDetailsDao().insertOrUpdate(GameSku.GAS, update.mayPurchase())
   Log.d(LOG_TAG, "updated AugmentedSkuDetails as well")
}

@WorkerThread
suspend private fun insert(entitlement: Entitlement) = withContext(Dispatchers.IO) {
   localCacheBillingClient.entitlementsDao().insert(entitlement)
}

The method processPurchases performs signature verification to make sure the purchaseToken hasn't been tampered with. For that, you need the security class. Create a Security Kotlin class file under billingrepo and add to it the following content. Ideally this class should live on your server, though for this codelab, you can just store it locally.

package com.example.playbilling.trivialdrive.kotlin.billingrepo

import android.text.TextUtils
import android.util.Base64
import android.util.Log
import java.io.IOException
import android.text.TextUtils
import android.util.Base64
import android.util.Log
import java.io.IOException
import java.security.InvalidKeyException
import java.security.KeyFactory
import java.security.NoSuchAlgorithmException
import java.security.PublicKey
import java.security.Signature
import java.security.SignatureException
import java.security.spec.InvalidKeySpecException
import java.security.spec.X509EncodedKeySpec

import java.security.spec.InvalidKeySpecException
import java.security.spec.X509EncodedKeySpec

object Security {
   private val TAG = "IABUtil/Security"
   private val KEY_FACTORY_ALGORITHM = "RSA"
   private val SIGNATURE_ALGORITHM = "SHA1withRSA"

/**
* This is the codelab's public key. For you own app, you must get your own.
*
* BASE_64_ENCODED_PUBLIC_KEY should be YOUR APPLICATION'S PUBLIC KEY
* (that you got from the Google Play developer console, usually under Services
* & APIs tab). This is not your developer public key, it's the *app-specific*
* public key.
*
* Just like everything else in this class, this public key should be kept on
* your server. But if you don't have a server, then you should obfuscate your
* app so that hackers cannot get it. If you cannot afford a sophisticated
* obfuscator, instead of just storing the entire literal string here embedded
* in the program,  construct the key at runtime from pieces or use bit
* manipulation (for example, XOR with some other string) to hide the actual
* key.  The key itself is not secret information, but we don't want to make it
* easy for an attacker to replace the public key with one of their own and
* then fake messages from the server.
*/
   val BASE_64_ENCODED_PUBLIC_KEY="MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAosfEirKDcXdAWuI" +
       "r4FVGvejeoCcJWKzSXIKnXpgzieP3dhQNEI1/fzxD8uAZuN8s3IhyFpazbftvS19v6ekHXr+cSFn1woCE4" +
       "S4nvVGjiWGGgFjazXrE7yRH7bVUwKRkSMZy/d4OVCWQ78Kqcuz0aCnTHzKsG95ZXnXqh6M4ZZlmFN+I8Uz" +
       "+w8/0K7Akr1ust28gkzzvQzKLJ+Nwka81ZKxARRQRD8pZac3jjrIzUm6RtPEMWqDxsLo9ZRWdkuyXM3RmX" +
       "TOkPUiuvliWa7CdNgldP3Uz+qDPlyWJ+oU/REa+1z4E0IPykgQ6LioAVdwIDUHS3oqm5Oq+VQD1w7ASIwI" +
       "DAQAB"


   /**
    * Verifies that the data was signed with the given signature
    *
    * @param base64PublicKey the base64-encoded public key to use for verifying.
    * @param signedData the signed JSON string (signed, not encrypted)
    * @param signature the signature for the data, signed with the private key
    * @throws IOException if encoding algorithm is not supported or key specification
    * is invalid
    */
   @Throws(IOException::class)
   fun verifyPurchase(base64PublicKey: String, signedData: String,
                      signature: String): Boolean {
       if ((TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey)
                       || TextUtils.isEmpty(signature))) {
           Log.w(TAG, "Purchase verification failed: missing data.")
           return false
       }
       val key = generatePublicKey(base64PublicKey)
       return verify(key, signedData, signature)
   }

   /**
    * Generates a PublicKey instance from a string containing the Base64-encoded public key.
    *
    * @param encodedPublicKey Base64-encoded public key
    * @throws IOException if encoding algorithm is not supported or key specification
    * is invalid
    */
   @Throws(IOException::class)
   private fun generatePublicKey(encodedPublicKey: String): PublicKey {
       try {
           val decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT)
           val keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM)
           return keyFactory.generatePublic(X509EncodedKeySpec(decodedKey))
       } catch (e: NoSuchAlgorithmException) {
           // "RSA" is guaranteed to be available.
           throw RuntimeException(e)
       } catch (e: InvalidKeySpecException) {
           val msg = "Invalid key specification: $e"
           Log.w(TAG, msg)
           throw IOException(msg)
       }
   }

   /**
    * Verifies that the signature from the server matches the computed
    * signature on the data.
    * Returns true if the data is correctly signed.
    *
    * @param publicKey public key associated with the developer account
    * @param signedData signed data from server
    * @param signature server signature
    * @return true if the data and signature match
    */
   private fun verify(publicKey: PublicKey, signedData: String, signature: String): Boolean {
       val signatureBytes: ByteArray
       try {
           signatureBytes = Base64.decode(signature, Base64.DEFAULT)
       } catch (e: IllegalArgumentException) {
           Log.w(TAG, "Base64 decoding failed.")
           return false
       }
       try {
           val signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM)
           signatureAlgorithm.initVerify(publicKey)
           signatureAlgorithm.update(signedData.toByteArray())
           if (!signatureAlgorithm.verify(signatureBytes)) {
               Log.w(TAG, "Signature verification failed...")
               return false
           }
           return true
       } catch (e: NoSuchAlgorithmException) {
           // "RSA" is guaranteed to be available.
           throw RuntimeException(e)
       } catch (e: InvalidKeyException) {
           Log.w(TAG, "Invalid key specification.")
       } catch (e: SignatureException) {
           Log.w(TAG, "Signature exception.")
       }
       return false
   }
}

launchBillingFlow and onPurchasesUpdated

launchBillingFlow is the method that's called when the user clicks to purchase an item. It prompts Google Play to start the buying flow. When Google Play is finished, it calls onPurchasesUpdated with the appropriate response. In turn, onPurchasesUpdated calls either queryPurchasesAsync or processPurchases directly.

fun launchBillingFlow(activity: Activity, augmentedSkuDetails: AugmentedSkuDetails) =
       launchBillingFlow(activity, SkuDetails(augmentedSkuDetails.originalJson))

fun launchBillingFlow(activity: Activity, skuDetails: SkuDetails) {
   val oldSku: String? = getOldSku(skuDetails.sku)
   val purchaseParams = BillingFlowParams.newBuilder().setSkuDetails(skuDetails)
           .setOldSku(oldSku).build()

   taskExecutionRetryPolicy(playStoreBillingClient, this) {
       playStoreBillingClient.launchBillingFlow(activity, purchaseParams)
   }
}

/**
* This sample app only offers one item for subscription: GoldStatus. And there are two
* ways a user can subscribe to GoldStatus: monthly or yearly. Hence it's easy for
* the [BillingRepository] to get the old sku if one exists. You too should have no problem
* knowing this fact about your app.
*
* We must here again reiterate. We don't want to make this sample app overwhelming. And so we
* are introducing ideas piecewise. This sample focuses more on overall architecture.
* So although we mention subscriptions, it is not about subscription per se. Classy Taxi is
* the sample app that focuses on subscriptions.
*
*/
private fun getOldSku(sku: String?): String? {
   var result: String? = null
   if (GameSku.SUBS_SKUS.contains(sku)) {
       goldStatusLiveData.value?.apply {
           result = when (sku) {
               GameSku.GOLD_MONTHLY -> GameSku.GOLD_YEARLY
               else -> GameSku.GOLD_YEARLY
           }
       }
   }
   return result
}

Now you can override the callback onPurchasesUpdated:

override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) {
   when (responseCode) {
       BillingClient.BillingResponse.OK -> {
           // will handle server verification, consumables, and updating the local cache
           purchases?.apply { processPurchases(toSet()) }
       }
       BillingClient.BillingResponse.ITEM_ALREADY_OWNED -> {
           //item already owned? call queryPurchasesAsync to verify and process all such items
           Log.d(LOG_TAG, "already owned items")
           queryPurchasesAsync()
       }
       BillingClient.BillingResponse.DEVELOPER_ERROR -> {
           Log.e(LOG_TAG, "Your app's configuration is incorrect. Review in the Google Play" +
                   "Console. Possible causes of this error include: APK is not signed with " +
                   "release key; SKU productId mismatch.")
       }
       else -> {
           Log.i(LOG_TAG, "BillingClient.BillingResponse error code: $responseCode")
       }
   }
}

onConsumeResponse

/**
* This is the callback for [BillingClient.consumeAsync]. It's called by [playStoreBillingClient] to notify
*  that a consume operation has finished.
* Appropriate action should be taken in the app, such as add fuel to user's
* car. This information should also be saved on the secure server in case user
* accesses the app through another device.
*/
override fun onConsumeResponse(responseCode: Int, purchaseToken: String?) {
   Log.d(LOG_TAG, "onConsumeResponse")
   when (responseCode) {
       BillingClient.BillingResponse.OK -> {
           //give user the items s/he just bought by updating the appropriate tables/databases
           purchaseToken?.apply { saveToLocalDatabase(this) }
           secureServerBillingClient.onComsumeResponse(purchaseToken, responseCode)
       }
       else -> {
           Log.w(LOG_TAG, "Error consuming purchase with token ($purchaseToken). " +
                   "Response code: $responseCode")
       }
   }
}

onBillingServiceDisconnected

This method is called when the app has inadvertently disconnected from the Google Play BillingClient. When this occurs, the app should attempt to reconnect using an exponential backoff policy. Note the distinction between BillingClient.endConnection and disconnected:

override fun onBillingServiceDisconnected() {
   Log.d(LOG_TAG, "onBillingServiceDisconnected")
   connectionRetryPolicy { connectToPlayBillingService() }
}

Build and run your project once more to make sure everything works as intended.

The hard part is done. Now you are going to define the BillingViewModel, which is just a public interface for your clients so they don't have to understand the internals of BillingRepository.

Create a top-level package called viewmodels and add the class BillingViewModel. As you might expect, as a public interface, the BillingViewModel exposes the Entitlements and the AugmentedSkuDetails objects. In addition, it exposes the launchBillingFlow method (under the alias makePurchase) so users can click to buy.

package com.example.playbilling.trivialdrive.kotlin.viewmodels

import android.app.Activity
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import com.example.playbilling.trivialdrive.kotlin.billingrepo.BillingRepository
import com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb.AugmentedSkuDetails
import com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb.GasTank
import com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb.GoldStatus
import com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb.PremiumCar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

/**
* Notice just how small and simple this BillingViewModel is!!
*
* This beautiful simplicity is the result of keeping all the hard work buried
* inside the [BillingRepository] and only inside the [BillingRepository]. The
* rest of your app is now free from [BillingClient] tentacles!! And this
* [BillingViewModel] is the one and only object the rest of your Android team
* need to know about billing.
*
*/
class BillingViewModel(application: Application) : AndroidViewModel(application) {

   val gasTankLiveData: LiveData<GasTank>
   val premiumCarLiveData: LiveData<PremiumCar>
   val goldStatusLiveData: LiveData<GoldStatus>
   val subsSkuDetailsListLiveData: LiveData<List<AugmentedSkuDetails>>
   val inappSkuDetailsListLiveData: LiveData<List<AugmentedSkuDetails>>

   private val LOG_TAG = "BillingViewModel"
   private val viewModelScope = CoroutineScope(Job() + Dispatchers.Main)
   private val repository: BillingRepository

   init {
       repository = BillingRepository.getInstance(application)
       repository.startDataSourceConnections()
       gasTankLiveData = repository.gasTankLiveData
       premiumCarLiveData = repository.premiumCarLiveData
       goldStatusLiveData = repository.goldStatusLiveData
       subsSkuDetailsListLiveData = repository.subsSkuDetailsListLiveData
       inappSkuDetailsListLiveData = repository.inappSkuDetailsListLiveData
   }

   /**
    * Not used in this sample app. But you may want to force refresh in your
    * own app (e.g. pull-to-refresh)
    */
   fun queryPurchases() = repository.queryPurchasesAsync()

   override fun onCleared() {
       super.onCleared()
       Log.d(LOG_TAG, "onCleared")
       repository.endDataSourceConnections()
       viewModelScope.coroutineContext.cancel()
   }

   fun makePurchase(activity: Activity, augmentedSkuDetails: AugmentedSkuDetails) {
       repository.launchBillingFlow(activity, augmentedSkuDetails)
   }

   /**
    * It's important to save after decrementing since gas can be updated by
    * both clients and the data sources.
    *
    * Note that even the ViewModel does not need to worry about thread safety
    * because the repo has already taken care it. So definitely the clients
    * also don't need to worry about thread safety.
    */
   fun decrementAndSaveGas() {
       val gas = gasTankLiveData.value
       gas?.apply {
           decrement()
           viewModelScope.launch {
               repository.updateGasTank(this@apply)
           }
       }
   }
}

It's time to put yourself in the shoes of the engineers who are going to be using your BillingRepository to do their work. You'll create an adapter to list the inventory. You'll also implement logic for using fuel, the premium car, and the "gold status."

InventoryAdapter

Add the layout file inventory_item to your project:

<androidx.cardview.widget.CardView
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:layout_margin="@dimen/margin_std_half"
   android:clickable="true"
   app:cardBackgroundColor="@color/colorAccentLighter"
   app:cardCornerRadius="@dimen/cardview_corner_radius"
   app:contentPadding="@dimen/cardview_padding"
>
   <androidx.constraintlayout.widget.ConstraintLayout
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
   >
       <androidx.appcompat.widget.AppCompatTextView
           android:id="@+id/sku_title"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:textStyle="bold"
           app:layout_constraintBottom_toBottomOf="@id/guideline"
           app:layout_constraintEnd_toStartOf="@id/sku_price"
           app:layout_constraintHorizontal_chainStyle="spread_inside"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toTopOf="parent"
           tools:text="This is a Title placeholder"/>
       <androidx.appcompat.widget.AppCompatTextView
           android:id="@+id/sku_price"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           app:layout_constraintBottom_toBottomOf="@id/guideline"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintStart_toEndOf="@id/sku_title"
           app:layout_constraintTop_toTopOf="parent"
           tools:text="$4.99"/>
       <androidx.constraintlayout.widget.Guideline
           android:id="@+id/guideline"
           android:layout_width="0dp"
           android:layout_height="match_parent"
           android:orientation="horizontal"
           app:layout_constraintGuide_percent="0.25"/>

       <androidx.appcompat.widget.AppCompatImageView
           android:id="@+id/sku_image"
           android:layout_width="wrap_content"
           android:layout_height="68dp"
           android:adjustViewBounds="true"
           android:maxWidth="126dp"
           android:scaleType="centerInside"
           app:layout_constraintBottom_toBottomOf="parent"
           app:layout_constraintEnd_toStartOf="@id/sku_description"
           app:layout_constraintHorizontal_chainStyle="packed"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toTopOf="@id/guideline"
       />

       <androidx.appcompat.widget.AppCompatTextView
           android:id="@+id/sku_description"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:layout_marginLeft="@dimen/margin_std_half"
           android:layout_marginRight="@dimen/margin_std_half"
           app:layout_constraintBottom_toBottomOf="parent"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintStart_toEndOf="@id/sku_image"
           app:layout_constraintTop_toTopOf="@id/guideline"
           tools:text="This is a description placeholder, telling users how cool this item is"/>
   </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

Create a top-level package called adapters and add the InventoryAdapter class. It's just a RecyclerView that focuses on keeping things very simple.

package com.example.playbilling.trivialdrive.kotlin.adapters

import android.content.res.Resources
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.playbilling.trivialdrive.kotlin.R
import com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb.AugmentedSkuDetails
import kotlinx.android.synthetic.main.inventory_item.view.*

/**
* This is an [AugmentedSkuDetails] adapter. It can be used anywhere there is a
* need to display a list of AugmentedSkuDetails. In this app it's used to
* display both the list of subscriptions and the list of in-app products.
*/
open class InventoryAdapter : RecyclerView.Adapter<InventoryAdapter.AugmentedSkuDetailsViewHolder>() {

   private var skuDetailsList = emptyList<AugmentedSkuDetails>()

   override fun getItemCount() = skuDetailsList.size

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AugmentedSkuDetailsViewHolder {
       val itemView = LayoutInflater.from(parent.context).inflate(R.layout.inventory_item, parent, false)
       return AugmentedSkuDetailsViewHolder(itemView)
   }

   override fun onBindViewHolder(holder: AugmentedSkuDetailsViewHolder, position: Int) {
       holder.bind(getItem(position))
   }

   fun getItem(position: Int) = if (skuDetailsList.isEmpty()) null else skuDetailsList[position]

   fun setSkuDetailsList(list: List<AugmentedSkuDetails>) {
       if (list != skuDetailsList) {
           skuDetailsList = list
           notifyDataSetChanged()
       }
   }

   /**
    * In the spirit of keeping simple things simple: this is a friendly way of
    * allowing clients to listen to clicks. You should consider doing this for
    * all your other adapters.
    */
   open fun onSkuDetailsClicked(item: AugmentedSkuDetails) {
       //clients to implement for callback if needed
   }

   inner class AugmentedSkuDetailsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
       init {
           itemView.setOnClickListener {
               getItem(adapterPosition)?.let { onSkuDetailsClicked(it) }
           }
       }

       fun bind(item: AugmentedSkuDetails?) {
           item?.apply {
               itemView?.apply {
                   val name = title?.substring(0, title.indexOf("("))
                   sku_title.text = name
                   sku_description.text = description
                   sku_price.text = price
                   val drawableId = getSkuDrawableId(sku, this)
                   sku_image.setImageResource(drawableId)
                   isEnabled = canPurchase
                   onDisabled(canPurchase, resources)
               }
           }
       }

       private fun onDisabled(enabled: Boolean, res: Resources) {
           if (enabled) {
               itemView?.apply {
                   setBackgroundColor(res.getColor(R.color.colorAccentLight))
                   sku_title.setTextColor(res.getColor(R.color.textColor))
                   sku_description.setTextColor(res.getColor(R.color.textColor))
                   sku_price.setTextColor(res.getColor(R.color.textColor))
                   sku_image.setColorFilter(null)
               }
           } else {
               itemView?.apply {
                   setBackgroundColor(res.getColor(R.color.textDisabledHint))
                   val color = res.getColor(R.color.imgDisableHint)
                   sku_image.setColorFilter(color)
                   sku_title.setTextColor(color)
                   sku_description.setTextColor(color)
                   sku_price.setTextColor(color)
               }
           }
       }

       /**
        *Keeping simple things simple, the icons are named after the SKUs.
        * This way, there is no need to create some elaborate system for
        * matching icons to SKUs when displaying the inventory to users.
        * It is sufficient to do
        *
        * ```
        * sku_image.setImageResource(resources.getIdentifier(sku,
        *       "drawable", view.context.packageName))
        *
        * ```
        *
        * Alternatively, in the case where more than one SKU should match the
        * same drawable, you can check with a when{} block. In this sample
        * app, for instance, both gold_monthly and gold_yearly should match
        * the same gold_subs_icon; so instead of keeping two copies of
        * the same icon, when{} is used to set imgName
        */
       private fun getSkuDrawableId(sku: String, view: View): Int {
           var imgName: String = when {
               sku.startsWith("gold_") -> "gold_subs_icon"
               else -> sku
           }
           val drawableId = view.resources.getIdentifier(imgName, "drawable",
                   view.context.packageName)
           return drawableId
       }
   }
}

Now open the MakePurchaseFragment and wire up the adapter to use the BillingViewModel's subsSkuDetailsListLiveData and inappSkuDetailsListLiveData.

package com.example.playbilling.trivialdrive.kotlin

import android.app.Activity
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.playbilling.trivialdrive.kotlin.adapters.InventoryAdapter
import com.example.playbilling.trivialdrive.kotlin.billingrepo.localdb.AugmentedSkuDetails
import com.example.playbilling.trivialdrive.kotlin.viewmodels.BillingViewModel
import kotlinx.android.synthetic.main.fragment_make_purchase.view.*

/**
* This Fragment is simply a wrapper for the inventory (i.e. items for sale).
* It contains two [lists][RecyclerView], one for subscriptions and one for
* in-app products. Here again there is no complicated billing logic. All the
* billing logic reside inside the [BillingRepository]. The [BillingRepository] 
* provides a [AugmentedSkuDetails] object that shows what is for sale and
* whether the user is allowed to buy the item at this moment. E.g. if the user
* already has a full tank of gas, then they cannot buy gas at this moment.
*/
class MakePurchaseFragment : Fragment() {

   val LOG_TAG = "MakePurchaseFragment"
   private lateinit var billingViewModel: BillingViewModel

   override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
       return inflater.inflate(R.layout.fragment_make_purchase, container, false)
   }

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)
       Log.d(LOG_TAG, "onViewCreated")

       val inappAdapter = object : InventoryAdapter() {
           override fun onSkuDetailsClicked(item: AugmentedSkuDetails) {
               onPurchase(view, item)
           }
       }

       val subsAdapter = object : InventoryAdapter() {
           override fun onSkuDetailsClicked(item: AugmentedSkuDetails) {
               onPurchase(view, item)
           }
       }
       attachAdapterToRecyclerView(view.inapp_inventory, inappAdapter)
       attachAdapterToRecyclerView(view.subs_inventory, subsAdapter)

       billingViewModel = ViewModelProviders.of(this).get(BillingViewModel::class.java)
       billingViewModel.inappSkuDetailsListLiveData.observe(this, Observer {
           it?.let { inappAdapter.setSkuDetailsList(it) }
       })
       billingViewModel.subsSkuDetailsListLiveData.observe(this, Observer {
           it?.let { subsAdapter.setSkuDetailsList(it) }
       })

   }

   private fun attachAdapterToRecyclerView(recyclerView: RecyclerView, skuAdapter: InventoryAdapter) {
       with(recyclerView) {
           layoutManager = LinearLayoutManager(requireContext())
           adapter = skuAdapter
       }
   }

   private fun onPurchase(view: View, item: AugmentedSkuDetails) {
       billingViewModel.makePurchase(activity as Activity, item)
       view.findNavController().navigate(R.id.action_playGame)
       Log.d(LOG_TAG, "starting purchase flow for SkuDetail:\n ${item}")
   }
}

Let's Drive!

Now update GameFragment and add logic to it so you can actually drive the car.

class GameFragment : androidx.fragment.app.Fragment() {
   private val LOG_TAG = "GameFragment"

   private var gasLevel: GasTank? = null
   private lateinit var billingViewModel: BillingViewModel

   override fun onCreateView(inflater: LayoutInflater, containter: ViewGroup?, savedInstanceState: Bundle?): View? {
       return inflater.inflate(R.layout.fragment_game, containter, false)
   }

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)

       btn_drive.setOnClickListener { onDrive() }
       btn_purchase.setOnClickListener { onPurchase(it) }

       billingViewModel = ViewModelProviders.of(this).get(BillingViewModel::class.java)
       billingViewModel.gasTankLiveData.observe(this, Observer {
           gasLevel = it
           showGasLevel()
       })
       billingViewModel.premiumCarLiveData.observe(this, Observer {
           it?.apply { showPremiumCar(entitled) }
       })
       billingViewModel.goldStatusLiveData.observe(this, Observer {
           it?.apply { showGoldStatus(entitled) }
       })
   }

   private fun onDrive() {
       gasLevel?.apply {
           if (!needGas()) {
               billingViewModel.decrementAndSaveGas()
               showGasLevel()
               Toast.makeText(context, getString(R.string.alert_drove), Toast.LENGTH_LONG).show()
           }
       }
       if (gasLevel?.needGas() != false) {
           Toast.makeText(context, getString(R.string.alert_no_gas), Toast.LENGTH_LONG).show()
       }
   }

   private fun onPurchase(view: View) {
       view.findNavController().navigate(R.id.action_makePurchase)
   }

   private fun showGasLevel() {
       gasLevel?.apply {
           val drawableName = "gas_level_${getLevel()}"
           val drawableId = resources.getIdentifier(drawableName, "drawable", requireActivity().packageName)
           gas_gauge.setImageResource(drawableId)
       }
       if (gasLevel == null) {
           gas_gauge.setImageResource(R.drawable.gas_level_0)
       }
   }

   private fun showPremiumCar(entitled: Boolean) {
       if (entitled) {
           free_or_premium_car.setImageResource(R.drawable.premium_car)
       } else {
           free_or_premium_car.setImageResource(R.drawable.free_car)
       }
   }

   private fun showGoldStatus(entitled: Boolean) {
       if (entitled) {
           gold_status.setBackgroundResource(R.drawable.gold_status)
       } else {
           gold_status.setBackgroundResource(0)
       }
   }

}

You did it! Congratulations! You've created a scalable implementation of Google Play Billing. That's a lot! Along the way, you learned the following:

For a documented version of the app you just built, see the official sample.

May your app prosper!