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>
|
|
||
| // Billing connection retry logic. This is a simple max retry pattern | ||
| private fun retryBillingServiceConnection() { | ||
| val maxTries = 3 |
There was a problem hiding this comment.
Critical Bug: This retry loop is synchronous, but billingClient.startConnection is asynchronous. The loop will execute all 3 attempts almost instantaneously without waiting for the connection result. isConnectionEstablished will always remain false during the loop's execution.
Suggestion: Implement a proper listener-based retry (e.g., schedule a retry with delay from onBillingServiceDisconnected or failed onBillingSetupFinished), or use coroutines to await the connection status before looping.
| } | ||
|
|
||
| // [START android_playbilling_errors_exponential_backoff] | ||
| private fun acknowledge(purchaseToken: String): BillingResult { |
There was a problem hiding this comment.
Bug: The helper function attempts to return ackResult synchronously, but it is set inside an asynchronous callback. It will always return a default-initialized BillingResult instead of the actual result from Play.
Suggestion: Since this file uses coroutines, convert this helper to a suspend function and use suspendCancellableCoroutine to await the callback, or use the KTX extension BillingClient.acknowledgePurchase which is already a suspend function.
| return ackResult | ||
| } | ||
|
|
||
| suspend fun acknowledgePurchase(purchaseToken: String) { |
There was a problem hiding this comment.
Bug: The code block starting with AcknowledgePurchaseResponseListener { ... } just instantiates a listener but never passes it to any function. The logic inside this listener (including the retry logic) is dead code and will never execute.
Suggestion: Rewrite the suspend function to use KTX extensions or properly chain the callbacks.
| // [START android_playbilling_errors_feature_support] | ||
| when { | ||
| billingClient.isReady -> { | ||
| if (billingClient.isFeatureSupported(BillingClient.FeatureType.IN_APP_MESSAGING)) { |
There was a problem hiding this comment.
Readability/Accuracy: This snippet uses billingClient.isFeatureSupported(...) expecting a Boolean. This only compiles because of the local helper ShadowBillingClient defined at the bottom of the file. Developers copying this snippet will get compile errors because the real BillingClient.isFeatureSupported returns BillingResult.
Suggestion: Remove the helper and show the real API usage in the snippet:
| if (billingClient.isFeatureSupported(BillingClient.FeatureType.IN_APP_MESSAGING)) { | |
| val billingResult = billingClient.isFeatureSupported(BillingClient.FeatureType.IN_APP_MESSAGING) | |
| if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { | |
| // use feature | |
| } |
| ).setSubscriptionUpdateParams( | ||
| BillingFlowParams.SubscriptionUpdateParams.newBuilder() | ||
| .setOldPurchaseToken("old_purchase_token") | ||
| .setSubscriptionReplacementMode( |
There was a problem hiding this comment.
Deprecated API: SubscriptionUpdateParams.Builder.setSubscriptionReplacementMode is deprecated in PBL v9.
Suggestion: Use SubscriptionProductReplacementParams.Builder.setReplacementMode instead.
| private fun initBillingClient() { | ||
| // [START android_playbilling_external_offers_init] | ||
| val billingClient = BillingClient.newBuilder(context) | ||
| .enableBillingProgram(BillingProgram.EXTERNAL_OFFER) |
There was a problem hiding this comment.
Deprecated API: BillingClient.Builder.enableBillingProgram(int) is deprecated in favor of enableBillingProgram(EnableBillingProgramParams).
Suggestion: Update to use EnableBillingProgramParams:
| .enableBillingProgram(BillingProgram.EXTERNAL_OFFER) | |
| .enableBillingProgram( | |
| EnableBillingProgramParams.newBuilder() | |
| .setBillingProgram(BillingProgram.EXTERNAL_OFFER) | |
| .build() | |
| ) |
| private fun initBillingClient() { | ||
| // [START android_playbilling_external_content_links_init] | ||
| val billingClient = BillingClient.newBuilder(context) | ||
| .enableBillingProgram(BillingProgram.EXTERNAL_CONTENT_LINK) |
There was a problem hiding this comment.
Deprecated API: BillingClient.Builder.enableBillingProgram(int) is deprecated in favor of enableBillingProgram(EnableBillingProgramParams).
Suggestion: Update to use EnableBillingProgramParams:
| .enableBillingProgram(BillingProgram.EXTERNAL_CONTENT_LINK) | |
| .enableBillingProgram( | |
| EnableBillingProgramParams.newBuilder() | |
| .setBillingProgram(BillingProgram.EXTERNAL_CONTENT_LINK) | |
| .build() | |
| ) |
Add Kotlin code snippets for play billing library