Created
December 16, 2022 07:18
-
-
Save Josuhu/9f0244b10a8135926289ab9e605635c3 to your computer and use it in GitHub Desktop.
Google Billing and In app purchases
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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