Skip to content

Instantly share code, notes, and snippets.

@Josuhu
Created December 16, 2022 07:18
Show Gist options
  • Save Josuhu/9f0244b10a8135926289ab9e605635c3 to your computer and use it in GitHub Desktop.
Save Josuhu/9f0244b10a8135926289ab9e605635c3 to your computer and use it in GitHub Desktop.
Google Billing and In app purchases
import android.app.Activity
import android.content.Context
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import com.android.billingclient.api.*
import com.android.billingclient.api.Purchase
import com.holdtorun.serverdog.application.dataStore
import com.holdtorun.serverdog.secrets.BillingId
import com.holdtorun.serverdog.tools.MyLogging
import com.holdtorun.serverdog.tools.MyToasts
import com.holdtorun.serverdog.tools.PrefsDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GoogleBilling(context: Context, val myToasts: MyToasts): PurchasesUpdatedListener {
private val TAG = "GoogleBilling"
// Pro mode statuses
private val activatedProdMode = 1
private val pendingProMode = 2
private val buyProMode = 3
// Toast texts
private val purchaseQueryFail = "Cannot query product"
private val inAppPurchaseNotSupported = "In app purchases not supported"
private val billingClientTimeOut = "Google billing client time out"
private val purchasePendingToast = "Pro version is pending, it should be finished soon"
private val billingClientNotReady = "Google BillingClient not ready, check connection and try again later"
private val purchaseSuccessToast = "Purchase success"
private val purchaseFailedToast = "Purchase failed, try again later"
private val purchaseCancelToast = "Purchase process cancelled"
private val purchaseOwnedToast = "Pro version is already purchased"
private val productNotOwned = "Pro version is not purchased"
private val billingServiceNotAvailable = "Google billing service not available"
private val itemNotAvailable = "Product not available"
lateinit var billingClient: BillingClient
private val dataStore = context.dataStore
private val myLogging = MyLogging()
private val scopeIO = CoroutineScope(Dispatchers.IO)
// private val scopeMAIN = CoroutineScope(Dispatchers.Main)
// For viewModels to follow this
val proMode = mutableStateOf(buyProMode)
private val myProductId = BillingId.Actual.myProductId
// Get promode status if needed
@Suppress("unused")
fun isProMode(): Boolean {
return proMode.value == activatedProdMode
}
// Get response functions for purchases
override fun onPurchasesUpdated(billinResult: BillingResult, purchases: MutableList<Purchase>?) {
when (billinResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases?.forEach { purchase ->
purchasesUpdatedResult(purchase, BillingClient.BillingResponseCode.OK)
}
}
// Handle any other status and error codes.
else -> { purchasesUpdatedResult(null, billinResult.responseCode) }
}
}
private fun purchasesUpdatedResult(purchase: Purchase? = null, responseCode: Int) {
when (responseCode) {
BillingClient.BillingResponseCode.OK -> {
val purchaseState = purchase?.purchaseState
if (purchaseState == Purchase.PurchaseState.PURCHASED) {
// Acknowledge the purchase
myToasts.toastText(purchaseSuccessToast)
ackPurchase(purchase)
scopeIO.launch { PrefsDataStore.saveProMode(true, dataStore) }
proMode.value = activatedProdMode
} else if (purchaseState == 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.
myToasts.toastText(purchasePendingToast)
scopeIO.launch { PrefsDataStore.saveProMode(false, dataStore) }
proMode.value = pendingProMode
}
}
// Handle an error caused by a user cancelling the purchase flow.
BillingClient.BillingResponseCode.USER_CANCELED -> {
myToasts.toastText(purchaseCancelToast)
}
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
myToasts.toastText(purchaseOwnedToast)
scopeIO.launch { PrefsDataStore.saveProMode(true, dataStore) }
proMode.value = activatedProdMode
}
BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> {
myToasts.toastText(productNotOwned)
scopeIO.launch { PrefsDataStore.saveProMode(false, dataStore) }
proMode.value = buyProMode
}
BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> {
myToasts.toastText(billingClientNotReady)
proMode.value = buyProMode
}
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> {
myToasts.toastText(billingServiceNotAvailable)
}
BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> {
myToasts.toastText(itemNotAvailable)
}
BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> {
myToasts.toastText(billingClientTimeOut)
}
// Handle any other error codes.
else -> {
myLogging.logThis(TAG, "purchasesUpdatedResult ResponseCode: $responseCode", Log.DEBUG)
scopeIO.launch {
if (!PrefsDataStore.getProMode(dataStore)) {
myToasts.toastText(purchaseFailedToast)
} else { myToasts.toastText(purchaseOwnedToast) }
}
}
}
}
// Initialize billingClient and response override functions
fun setupBillingClient(context: Context) {
billingClient = BillingClient.newBuilder(context).enablePendingPurchases().setListener(this).build()
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(p0: BillingResult) {
if (p0.responseCode == (BillingClient.BillingResponseCode.OK)) {
myLogging.logThis(TAG, "onBillingSetupFinished CONNECTED", Log.DEBUG)
billingSetupFinishedResult(true)
} else {
myLogging.logThis(TAG, "onBillingSetupFinished FAILED", Log.DEBUG)
billingSetupFinishedResult(false)
}
}
override fun onBillingServiceDisconnected() {
myLogging.logThis(TAG, "onBillingServiceDisconnected DISCONNECTED", Log.DEBUG)
// Retry connection
billingClient.startConnection(this)
}
})
}
fun billingSetupFinishedResult(result: Boolean) {
if (result) {
// Call billingHistory to get update on local cached bought products
scopeIO.launch { queryPurchasesAsync() }
} else { proMode.value = buyProMode }
}
// Check billing history from devices Google Play cache for bought products and update Pro status with it
fun queryPurchasesAsync() {
// Start query and jump to onPurchaseHistoryResponse to process the results
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
) { billingResult, purchaseList ->
myLogging.logThis(TAG, "queryPurchasesAsync purchaseList: $purchaseList, billingResult Code: ${billingResult.responseCode}.", Log.DEBUG)
// Confirm if owned product is found from devices Google Play cache memory which is updated while online.
if (purchaseList.isNotEmpty()) {
purchaseList.forEach {
if (it.products.contains(myProductId) && it.purchaseState == Purchase.PurchaseState.PURCHASED) {
myLogging.logThis(TAG, "queryPurchasesAsync OWNED $myProductId", Log.DEBUG)
queryPurchasesAsyncResult(it, it.purchaseState)
} else if (it.products.contains(myProductId) && it.purchaseState == Purchase.PurchaseState.UNSPECIFIED_STATE) {
myLogging.logThis(TAG, "queryPurchasesAsync NOT OWNED $myProductId", Log.DEBUG)
queryPurchasesAsyncResult(it, it.purchaseState)
} else if (it.products.contains(myProductId) && it.purchaseState == Purchase.PurchaseState.PENDING) {
myLogging.logThis(TAG, "queryPurchasesAsync PENDING $myProductId", Log.DEBUG)
queryPurchasesAsyncResult(it, it.purchaseState)
}
}
} else {
// If purchases list is empty it means the purchase is not done, is refunded or cancelled
// NOTE! Do not clear/revoke purchase history with this information as local cache might be cleared manually!
// It´s safe to deny Pro features until next online Play connection updates the possibly bought product.
myLogging.logThis(TAG, "queryPurchasesAsync purchaList is Empty", Log.DEBUG)
queryPurchasesAsyncResult(null, -1000)
}
}
}
// Handle existing purchases
private fun queryPurchasesAsyncResult(purchase: Purchase?, purchaseState: Int) {
when (purchaseState) {
// Acknowledge the purchase
Purchase.PurchaseState.PURCHASED -> {
if (purchase != null) {
ackPurchase(purchase)
scopeIO.launch { PrefsDataStore.saveProMode(true, dataStore) }
proMode.value = activatedProdMode
}
}
Purchase.PurchaseState.UNSPECIFIED_STATE -> {
scopeIO.launch { PrefsDataStore.saveProMode(false, dataStore) }
proMode.value = buyProMode
}
Purchase.PurchaseState.PENDING -> {
myToasts.toastText(purchasePendingToast)
scopeIO.launch { PrefsDataStore.saveProMode(false, dataStore) }
proMode.value = pendingProMode
}
// Deny Pro version until confirmed cache update is received from Google PurchaseResult
else -> {
scopeIO.launch { PrefsDataStore.saveProMode(false, dataStore) }
proMode.value = buyProMode
}
}
}
// Acknowledge must be done within 3 days after purchase to avoid refund!!!
private fun ackPurchase(purchase: Purchase) {
if (!purchase.isAcknowledged) {
val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
billingClient.acknowledgePurchase(params) { billingResult ->
val responseCode = billingResult.responseCode
val debugMessage = billingResult.debugMessage
myLogging.logThis(TAG, "BILLING ACKNOWLEDGE Status: ${purchase.isAcknowledged}, " +
"ResponseCode: $responseCode, Debug message: $debugMessage", Log.DEBUG
)
}
}
}
fun startBuyProcess(activity: Activity) {
if (billingClient.isReady) {
queryProductDetails(activity)
} else {
myToasts.toastText(billingClientNotReady)
proMode.value = buyProMode
}
}
// Start Buy process with this
private fun queryProductDetails(activity: Activity) {
billingClient.queryProductDetailsAsync(returnParams().build()) { billingResult, productDetailsList ->
// println("Result: ${billingResult.responseCode}, SUCCESS: ${BillingClient.BillingResponseCode.OK}")
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetailsList.forEach { productDetails ->
if (productDetails.productId == myProductId) {
queryProductDetailsResult(activity, true, productDetails)
}
}
} else { queryProductDetailsResult(activity,false, null) }
}
}
// Show user the Google Billing window
private fun queryProductDetailsResult(activity: Activity, showBillingWindow: Boolean, productDetails: ProductDetails?) {
if (showBillingWindow && productDetails != null) {
val response = billingClient.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS)
if (response.responseCode == BillingClient.BillingResponseCode.OK) {
displayBillingWindow(productDetails, activity)
} else { myToasts.toastText(inAppPurchaseNotSupported) }
} else { myToasts.toastText(purchaseQueryFail) }
}
private fun returnParams(): QueryProductDetailsParams.Builder {
val productList = listOf(QueryProductDetailsParams.Product.newBuilder()
.setProductId(myProductId)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
return QueryProductDetailsParams.newBuilder().setProductList(productList)
}
/** DO NOT USE OFFER TOKEN IN ONE TIME PURCHASE*/
private fun displayBillingWindow(productDetails: ProductDetails, activity: Activity) {
// val offerToken = productDetails.subscriptionOfferDetails[selectedOfferHere].offerToken
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
//.setOfferToken(offerToken.toString())
.build()
)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.build()
billingClient.launchBillingFlow(activity, billingFlowParams)
}
/** FOR FURTHER BILLING functions if needed.*/
// Call this if you really revoke users right for the bought app.
// Will clear all inApp purchase Tokens. Makes inApp product reusable.
suspend fun clearHistory() {
billingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()).purchasesList.forEach {
val consumeParams = ConsumeParams.newBuilder().setPurchaseToken(it.purchaseToken).build()
billingClient.consumeAsync(consumeParams) { responseCode, purchaseToken ->
if (responseCode.responseCode == BillingClient.BillingResponseCode.OK) {
myLogging.logThis(TAG,"clearHistory Updated consumeAsync, purchases token removed: $purchaseToken", Log.DEBUG)
} else {
myLogging.logThis(TAG,"clearHistory some troubles happened: $responseCode", Log.DEBUG)
}
}
}
}
// Use this to check UNMASKED purchaseStates. Will reveal with 0 value if is not really owned
// fun getUnmaskedPurchaseState(purchase: Purchase?, unMasked: Boolean): Int {
// purchase ?: return 0
// if (!unMasked) { return purchase.purchaseState }
// return try {
// val purchaseState = JSONObject(purchase.originalJson).optInt("purchaseState", 0)
// myLogging.logThis(TAG, "getUnmaskedPurchaseState: $purchaseState, maskedPurchaseState: ${purchase.purchaseState}")
// purchaseState
// } catch (e: JSONException) {
// Log.e(TAG, "getUnmaskedPurchaseState: ${e.message.toString()}")
// 0
// }
// }
// Use this only if want to check all over purchase history. Includes purchaced, cancelled, consumed and outdated log.
// Do not enable any app functionality with this.
// fun queryPurchaseHistoryAsync() {
// billingClient.queryPurchaseHistoryAsync(BillingClient.SkuType.INAPP) { billingResult, purchaseHistory ->
// if(billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
// myLogging.logThis(TAG,"queryPurchaseHistoryAsync ${purchaseHistory.toString()}")
// } else { Log.e(TAG,"queryPurchaseHistoryAsync FAIL") }
// }
// }
// Call this to make inApp purchase reusable after successful buy
// private fun allowMultiplePurchases(purchases: MutableList<Purchase>?) {
// val purchase = purchases?.first()
// if (purchase != null) {
// val consumeParams = ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
// billingClient.consumeAsync(consumeParams) { responseCode, purchaseToken ->
// if (responseCode.responseCode == BillingClient.BillingResponseCode.OK && purchaseToken != null) {
// println("AllowMultiplePurchases success, responseCode: $responseCode")
// } else {
// println("Can't allowMultiplePurchases, responseCode: $responseCode")
// }
// }
// }
// }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment