Skip to content

Kotlin code snippets for play billing library#935

Draft
sidd607 wants to merge 6 commits into
mainfrom
sidd607/playbilling-snippets
Draft

Kotlin code snippets for play billing library#935
sidd607 wants to merge 6 commits into
mainfrom
sidd607/playbilling-snippets

Conversation

@sidd607

@sidd607 sidd607 commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Add Kotlin code snippets for play billing library

@sidd607 sidd607 requested review from kkuan2011 and yrezgui as code owners June 2, 2026 09:33
@snippet-bot

snippet-bot Bot commented Jun 2, 2026

Copy link
Copy Markdown

Here is the summary of changes.

You are about to add 39 region tags.

This comment is generated by snippet-bot.
If you find problems with this result, please file an issue at:
https://github.com/googleapis/repo-automation-bots/issues.
To update this comment, add snippet-bot:force-run label or use the checkbox below:

  • Refresh this comment

@sidd607 sidd607 self-assigned this Jun 2, 2026
@sidd607 sidd607 marked this pull request as draft June 2, 2026 09:34

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +107 to +188
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!")
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of acknowledge and acknowledgePurchase has several critical issues:

  1. acknowledge is synchronous but calls the asynchronous billingClient.acknowledgePurchase, returning a dummy BillingResult before the callback runs.
  2. AcknowledgePurchaseResponseListener { ... } is a floating lambda expression that is created but never registered or executed, meaning all error handling and retry logic is dead code.
  3. runBlocking is 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!")
      }
    }
  }

Comment on lines +65 to +94
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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()
  }

Comment on lines +190 to +218
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  }

Comment thread playbilling/src/main/java/com/example/pbl/kotlin/Integrate.kt
Comment thread playbilling/src/main/java/com/example/pbl/kotlin/Integrate.kt Outdated
Comment thread playbilling/src/main/java/com/example/pbl/kotlin/Subscriptions.kt Outdated
sidd607 and others added 4 commits June 2, 2026 16:32
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@sidd607 sidd607 requested a review from trwhite-google June 3, 2026 15:40

// Billing connection retry logic. This is a simple max retry pattern
private fun retryBillingServiceConnection() {
val maxTries = 3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deprecated API: BillingClient.Builder.enableBillingProgram(int) is deprecated in favor of enableBillingProgram(EnableBillingProgramParams).

Suggestion: Update to use EnableBillingProgramParams:

Suggested change
.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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deprecated API: BillingClient.Builder.enableBillingProgram(int) is deprecated in favor of enableBillingProgram(EnableBillingProgramParams).

Suggestion: Update to use EnableBillingProgramParams:

Suggested change
.enableBillingProgram(BillingProgram.EXTERNAL_CONTENT_LINK)
.enableBillingProgram(
EnableBillingProgramParams.newBuilder()
.setBillingProgram(BillingProgram.EXTERNAL_CONTENT_LINK)
.build()
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants