Kotlin code snippets for play billing library#935
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces Kotlin-based integration examples for the Google Play Billing Library, covering connection management, error handling, subscription replacements, alternative billing flows, and external links. While the implementation provides a comprehensive set of examples, several critical issues need to be addressed. Specifically, in Errors.kt, the asynchronous acknowledgement flow is broken due to a floating lambda and incorrect coroutine usage, the retry connection logic spawns concurrent connection attempts instead of sequential ones, and the exponential backoff helper fails to return early on success. Additionally, the BillingClient in Integrate.kt is configured with a dummy listener instead of the class's own implementation, and unsafe double-bang operators are used in subscription offer lookups, which could lead to null pointer exceptions.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private fun acknowledge(purchaseToken: String): BillingResult { | ||
| val params = AcknowledgePurchaseParams.newBuilder() | ||
| .setPurchaseToken(purchaseToken) | ||
| .build() | ||
| var ackResult = BillingResult() | ||
| billingClient.acknowledgePurchase(params) { billingResult -> | ||
| ackResult = billingResult | ||
| } | ||
| return ackResult | ||
| } | ||
|
|
||
| suspend fun acknowledgePurchase(purchaseToken: String) { | ||
|
|
||
| val retryDelayMs = 2000L | ||
| val retryFactor = 2 | ||
| val maxTries = 3 | ||
|
|
||
| withContext(Dispatchers.IO) { | ||
| acknowledge(purchaseToken) | ||
| } | ||
|
|
||
| AcknowledgePurchaseResponseListener { acknowledgePurchaseResult -> | ||
| val playBillingResponseCode = | ||
| PlayBillingResponseCode(acknowledgePurchaseResult.responseCode) | ||
| when (playBillingResponseCode) { | ||
| BillingClient.BillingResponseCode.OK -> { | ||
| Log.i(TAG, "Acknowledgement was successful") | ||
| } | ||
| BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> { | ||
| // This is possibly related to a stale Play cache. | ||
| // Querying purchases again. | ||
| Log.d(TAG, "Acknowledgement failed with ITEM_NOT_OWNED") | ||
| billingClient.queryPurchasesAsync( | ||
| QueryPurchasesParams.newBuilder() | ||
| .setProductType(BillingClient.ProductType.SUBS) | ||
| .build() | ||
| ) | ||
| { billingResult, purchaseList -> | ||
| when (billingResult.responseCode) { | ||
| BillingClient.BillingResponseCode.OK -> { | ||
| purchaseList.forEach { purchase -> | ||
| acknowledge(purchase.purchaseToken) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| in setOf( | ||
| BillingClient.BillingResponseCode.ERROR, | ||
| BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, | ||
| BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, | ||
| ) -> { | ||
| Log.d( | ||
| TAG, | ||
| "Acknowledgement failed, but can be retried -- " + | ||
| "Response Code: ${acknowledgePurchaseResult.responseCode} -- " + | ||
| "Debug Message: ${acknowledgePurchaseResult.debugMessage}" | ||
| ) | ||
| runBlocking { | ||
| exponentialRetry( | ||
| maxTries = maxTries, | ||
| initialDelay = retryDelayMs, | ||
| retryFactor = retryFactor | ||
| ) { acknowledge(purchaseToken) } | ||
| } | ||
| } | ||
| in setOf( | ||
| BillingClient.BillingResponseCode.BILLING_UNAVAILABLE, | ||
| BillingClient.BillingResponseCode.DEVELOPER_ERROR, | ||
| BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED, | ||
| ) -> { | ||
| Log.e( | ||
| TAG, | ||
| "Acknowledgement failed and cannot be retried -- " + | ||
| "Response Code: ${acknowledgePurchaseResult.responseCode} -- " + | ||
| "Debug Message: ${acknowledgePurchaseResult.debugMessage}" | ||
| ) | ||
| throw Exception("Failed to acknowledge the purchase!") | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The current implementation of acknowledge and acknowledgePurchase has several critical issues:
acknowledgeis synchronous but calls the asynchronousbillingClient.acknowledgePurchase, returning a dummyBillingResultbefore the callback runs.AcknowledgePurchaseResponseListener { ... }is a floating lambda expression that is created but never registered or executed, meaning all error handling and retry logic is dead code.runBlockingis used inside a suspend function, which blocks the thread and is a bad practice.
Since acknowledgePurchase is a suspend function, you should use coroutines properly. We can wrap the callback-based APIs in suspendCancellableCoroutine to make them proper suspend functions, allowing for clean, sequential, and non-blocking code.
private suspend fun acknowledge(purchaseToken: String): BillingResult =
kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()
billingClient.acknowledgePurchase(params) { billingResult ->
continuation.resumeWith(Result.success(billingResult))
}
}
private suspend fun queryPurchases(productType: String): Pair<BillingResult, List<Purchase>> =
kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
val params = QueryPurchasesParams.newBuilder()
.setProductType(productType)
.build()
billingClient.queryPurchasesAsync(params) { billingResult, purchaseList ->
continuation.resumeWith(Result.success(Pair(billingResult, purchaseList)))
}
}
suspend fun acknowledgePurchase(purchaseToken: String) {
val retryDelayMs = 2000L
val retryFactor = 2
val maxTries = 3
val acknowledgePurchaseResult = acknowledge(purchaseToken)
val playBillingResponseCode = acknowledgePurchaseResult.responseCode
when (playBillingResponseCode) {
BillingClient.BillingResponseCode.OK -> {
Log.i(TAG, "Acknowledgement was successful")
}
BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> {
Log.d(TAG, "Acknowledgement failed with ITEM_NOT_OWNED")
val (billingResult, purchaseList) = queryPurchases(BillingClient.ProductType.SUBS)
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
purchaseList.forEach { purchase ->
acknowledge(purchase.purchaseToken)
}
}
}
in setOf(
BillingClient.BillingResponseCode.ERROR,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
) -> {
Log.d(
TAG,
"Acknowledgement failed, but can be retried -- " +
"Response Code: ${acknowledgePurchaseResult.responseCode} -- " +
"Debug Message: ${acknowledgePurchaseResult.debugMessage}"
)
exponentialRetry(
maxTries = maxTries,
initialDelay = retryDelayMs,
retryFactor = retryFactor
) { acknowledge(purchaseToken) }
}
in setOf(
BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
BillingClient.BillingResponseCode.DEVELOPER_ERROR,
BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
) -> {
Log.e(
TAG,
"Acknowledgement failed and cannot be retried -- " +
"Response Code: ${acknowledgePurchaseResult.responseCode} -- " +
"Debug Message: ${acknowledgePurchaseResult.debugMessage}"
)
throw Exception("Failed to acknowledge the purchase!")
}
}
}| private fun retryBillingServiceConnection() { | ||
| val maxTries = 3 | ||
| var tries = 1 | ||
| var isConnectionEstablished = false | ||
| do { | ||
| try { | ||
| billingClient.startConnection(object : BillingClientStateListener { | ||
| override fun onBillingSetupFinished(billingResult: BillingResult) { | ||
| if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { | ||
| isConnectionEstablished = true | ||
| Log.d(TAG, "Billing connection retry succeeded.") | ||
| } else { | ||
| Log.e( | ||
| TAG, | ||
| "Billing connection retry failed: ${billingResult.debugMessage}" | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| override fun onBillingServiceDisconnected() { | ||
| // Retry logic or logging | ||
| } | ||
| }) | ||
| } catch (e: Exception) { | ||
| e.message?.let { Log.e(TAG, it) } | ||
| } finally { | ||
| tries++ | ||
| } | ||
| } while (tries <= maxTries && !isConnectionEstablished) | ||
| } |
There was a problem hiding this comment.
The retryBillingServiceConnection function uses a synchronous do-while loop to call billingClient.startConnection, which is an asynchronous operation. This will immediately spawn 3 concurrent connection attempts without waiting for the previous attempt to succeed or fail, and without any delay between retries. Instead, use a recursive or callback-based approach to retry sequentially.
private fun retryBillingServiceConnection() {
val maxTries = 3
var tries = 1
fun connect() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Log.d(TAG, "Billing connection retry succeeded.")
} else {
Log.e(TAG, "Billing connection retry failed: ${billingResult.debugMessage}")
if (tries < maxTries) {
tries++
connect()
}
}
}
override fun onBillingServiceDisconnected() {
// Handle disconnected
}
})
}
connect()
}| private suspend fun <T> exponentialRetry( | ||
| maxTries: Int = Int.MAX_VALUE, | ||
| initialDelay: Long = Long.MAX_VALUE, | ||
| retryFactor: Int = Int.MAX_VALUE, | ||
| block: suspend () -> T | ||
| ): T? { | ||
| var currentDelay = initialDelay | ||
| var retryAttempt = 1 | ||
| do { | ||
| runCatching { | ||
| delay(currentDelay) | ||
| block() | ||
| } | ||
| .onSuccess { | ||
| Log.d(TAG, "Retry succeeded") | ||
| return@onSuccess | ||
| } | ||
| .onFailure { throwable -> | ||
| Log.e( | ||
| TAG, | ||
| "Retry Failed -- Cause: ${throwable.cause} -- Message: ${throwable.message}" | ||
| ) | ||
| } | ||
| currentDelay *= retryFactor | ||
| retryAttempt++ | ||
| } while (retryAttempt < maxTries) | ||
|
|
||
| return block() // last attempt | ||
| } |
There was a problem hiding this comment.
The return@onSuccess statement on line 205 only returns from the onSuccess lambda, not from the enclosing exponentialRetry function. As a result, the retry loop will continue to execute even after a successful attempt. Additionally, using runCatching with explicit success/failure checks is cleaner and avoids lambda scoping issues.
private suspend fun <T> exponentialRetry(
maxTries: Int = Int.MAX_VALUE,
initialDelay: Long = Long.MAX_VALUE,
retryFactor: Int = Int.MAX_VALUE,
block: suspend () -> T
): T? {
var currentDelay = initialDelay
var retryAttempt = 1
do {
val result = runCatching {
delay(currentDelay)
block()
}
if (result.isSuccess) {
Log.d(TAG, "Retry succeeded")
return result.getOrNull()
} else {
val throwable = result.exceptionOrNull()
Log.e(
TAG,
"Retry Failed -- Cause: ${throwable?.cause} -- Message: ${throwable?.message}"
)
}
currentDelay *= retryFactor
retryAttempt++
} while (retryAttempt < maxTries)
return runCatching { block() }.getOrNull() // last attempt
}Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Add Kotlin code snippets for play billing library