3

I'm following the google billing integration instructions and have got stuck with how to await for the billing client connection result.

Whenever I need to query sku details or purchases I need to make sure that the billing client is initialized and connected. There are querySkuDetails and queryPurchasesAsync awaitable kotlin extension functions, but startConnection is based on listeners instead. Here are code samples from the docs.

private var billingClient = BillingClient.newBuilder(activity)
   .setListener(purchasesUpdatedListener)
   .enablePendingPurchases()
   .build()

billingClient.startConnection(object : BillingClientStateListener {
    override fun onBillingSetupFinished(billingResult: BillingResult) {
        if (billingResult.responseCode ==  BillingResponseCode.OK) {
            // The BillingClient is ready. You can query purchases here.
        }
    }
    override fun onBillingServiceDisconnected() {
        // Try to restart the connection on the next request to
        // Google Play by calling the startConnection() method.
    }
})

suspend fun querySkuDetails() {
    // prepare params
    // leverage querySkuDetails Kotlin extension function
    val skuDetailsResult = withContext(Dispatchers.IO) {
        billingClient.querySkuDetails(params.build())
    }
    // Process the result.
}

How to put all this together using suspend functions?

yaugenka
  • 2,602
  • 2
  • 22
  • 41

3 Answers3

2

As discussed in BillingClient.BillingClientStateListener.onBillingSetupFinished is called multiple times, it is not possible to translate startConnection into the Coroutine world, because its callback will be called multiple times.

mtotschnig
  • 1,238
  • 10
  • 30
1

One way to create a suspending version of startConnection is the following:

/**
 * Returns immediately if this BillingClient is already connected, otherwise
 * initiates the connection and suspends until this client is connected.
 */
suspend fun BillingClient.ensureReady() {
    if (isReady) {
        return // fast path if already connected
    }
    return suspendCoroutine { cont ->
        startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (billingResult.responseCode == BillingResponseCode.OK) {
                    cont.resume(Unit)
                } else {
                    // you could also use a custom, more precise exception
                    cont.resumeWithException(RuntimeException("Billing setup failed: ${billingResult.debugMessage} (code ${billingResult.responseCode})"))
                }
            }

            override fun onBillingServiceDisconnected() {
                // no need to setup reconnection logic here, call ensureReady() 
                // before each purchase to reconnect as necessary
            }
        })
    }
}

This will fail if another coroutine already initiated a connection. If you want to deal with potential concurrent calls to this method, you can use a mutex to protect the connection part:

val billingConnectionMutex = Mutex()

/**
 * Returns immediately if this BillingClient is already connected, otherwise
 * initiates the connection and suspends until this client is connected.
 * If a connection is already in the process of being established, this
 * method just suspends until the billing client is ready.
 */
suspend fun BillingClient.ensureReady() {
    billingConnectionMutex.withLock {
        // fast path: avoid suspension if another coroutine already connected
        if (isReady) {
            return
        }
        connectOrThrow()
    }
}

private suspend fun BillingClient.connectOrThrow() = suspendCoroutine<Unit> { cont ->
    startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(billingResult: BillingResult) {
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                cont.resume(Unit)
            } else {
                cont.resumeWithException(RuntimeException("Billing setup failed: ${billingResult.debugMessage} (code ${billingResult.responseCode})"))
            }
        }

        override fun onBillingServiceDisconnected() {
            // no need to setup reconnection logic here, call ensureReady()
            // before each purchase to reconnect as necessary
        }
    })
}

Here, the release of the mutex corresponds to the end of connectOrThrow() for whichever other coroutine was holding it, so it's released as soon as the connection succeeds. If that other connection fails, this method will attempt the connection itself, and will succeed or fail on its own so the caller will be notified in case of error.

If you prefer to deal with result codes directly in if statements, you can return results instead of throwing:

private val billingConnectionMutex = Mutex()

private val resultAlreadyConnected = BillingResult.newBuilder()
    .setResponseCode(BillingClient.BillingResponseCode.OK)
    .setDebugMessage("Billing client is already connected")
    .build()

/**
 * Returns immediately if this BillingClient is already connected, otherwise
 * initiates the connection and suspends until this client is connected.
 * If a connection is already in the process of being established, this
 * method just suspends until the billing client is ready.
 */
suspend fun BillingClient.connect(): BillingResult = billingConnectionMutex.withLock {
    if (isReady) {
        // fast path: avoid suspension if already connected
        resultAlreadyConnected
    } else {
        unsafeConnect()
    }
}

private suspend fun BillingClient.unsafeConnect() = suspendCoroutine<BillingResult> { cont ->
    startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(billingResult: BillingResult) {
            cont.resume(billingResult)
        }
        override fun onBillingServiceDisconnected() {
            // no need to setup reconnection logic here, call ensureReady()
            // before each purchase to reconnect as necessary
        }
    })
}
Joffrey
  • 32,348
  • 6
  • 68
  • 100
  • How would you suspend in case when `BillingClient.ConnectionState.CONNECTING`? – yaugenka Jun 24 '21 at 06:19
  • @yaugenka Sorry I'm not too familiar with this API, I kinda missed this use case. If the state is "CONNECTING", it looks like `startConnection` immediately calls the callback with a result `DEVELOPER_ERROR` (5), which would throw an exception with my current code. To manage that, you might want to protect the suspendCoroutine part of `ensureReady` with a `kotlinx.coroutines.sync.Mutex` – Joffrey Jun 24 '21 at 09:52
  • Another approach could be to poll the connection state until it becomes `CONNECTED`, but I'm not such a big fan of polling if we can just wait for the proper moment. – Joffrey Jun 24 '21 at 10:59
  • Is it possible to resume with the billingResult returned, in order to make it work like this: `val result = BillingClient.ensureReady(); if (result.billingResult.responseCode ==...` ? – yaugenka Jul 03 '21 at 15:39
  • @yaugenka it is definitely possible to change the above code to return the billing result instead of throwing exceptions, but I wonder what the benefit would be here. Could you please elaborate what the purpose of that `if` would be? – Joffrey Jul 03 '21 at 21:49
  • Note that the semantics of `ensureReady()` here is to ensure the billing service is ready before the rest of the code executes. The contract is that if the method returns successfully, the service is ready to be used. Any other response code would mean the service is not ready, this is why the current design throws an exception. If you want something different, this invariant need to be changed as well as the name of the method. The design of the usage must be revisited. More specifically, how do you expect to handle other states on the call site? – Joffrey Jul 03 '21 at 22:19
  • All subsequent async methods in the billing flow `querySkuDetails` -> `launchBillingFlow` -> `onPurchasesUpdated` -> `acknowledgePurchase`, return a billingResult which you check before proceeding further in the chain. `startConnection` is the only one falling out of this pattern. Throwing an exception from within the connection method requires wrapping `BillingClient.ensureReady()` into try/catch which is basically the same as checking billingResult from outside the method, but the latter would be inline with the common billing flow pattern. – yaugenka Jul 04 '21 at 10:53
  • @yaugenka *Throwing an exception from within the connection method requires wrapping BillingClient.ensureReady() into try/catch* - only if there is something you can do about it. I understand the reasoning, my question was more practical. Those other methods have meaningful results that you can act upon. From what I can see, there is little you can do about the errors you could get from `startConnection` (which are programmer errors AFAICT). That's why I asked what you would do in your `if` statement. Which exact error codes would you want to handle in an `if` after trying to connect? – Joffrey Jul 04 '21 at 15:40
  • There are a number of error codes which might need to be handled like ERROR, SERVICE_UNAVAILABLE, SERVICE_TIMEOUT but the general approach would be `if (billingResult.responseCode == BillingResponseCode.OK) proceed_with_the_billing_flow else log_error` assuming we are suspended when in `CONNECTING` state like you've done nicely with the mutex. – yaugenka Jul 04 '21 at 20:15
  • Thanks to your solution and comments I have managed to construct it. See the posted answer below. – yaugenka Jul 05 '21 at 09:35
  • @yaugenka I'm sorry I thought I had actually added the code to my answer while asking these questions. I'm glad you could derive it yourself anyway :) I didn't see anywhere those error codes can be returned from `startConnection`, but it might be from inside the service bind call, so I'll trust you on that. In any case, you should probably not just log an error in these cases. The calling code most likely has to do something (which probably includes notifying the user) hence why exceptions are easier to use to propagate this error up where this handling can happen. – Joffrey Jul 05 '21 at 12:03
0

Thanks to Joffrey's answer and comments I have managed to construct a solution with BillingResult returned

val billingConnectionMutex = Mutex()

suspend fun BillingClient.connect(): BillingResult =
    billingConnectionMutex.withLock {
        suspendCoroutine { cont ->
            startConnection(object : BillingClientStateListener {
                override fun onBillingSetupFinished(billingResult: BillingResult) {
                    if (billingResult.responseCode != BillingResponseCode.OK)
                        Log.d(TAG, "Billing setup failed: ${billingResult.debugMessage} (code ${billingResult.responseCode})")
                    cont.resume(billingResult)
                }

                override fun onBillingServiceDisconnected() {}
            })
        }
    }

and then call it like this within the billing flow

if(billingClient!!.connect().responseCode != BillingResponseCode.OK)
    return

If billingClient is already connected it will return responseCode.OK, so no need to check for isReady.

yaugenka
  • 2,602
  • 2
  • 22
  • 41
  • You don't have to give up on the fast path, you can manually return the `OK` result without suspending when `isReady` is already true (without attempting to reconnect each time). – Joffrey Jul 05 '21 at 11:25
  • *no need to check for isReady* - it's true it's not needed for correctness, it's only here for performance. The first check in my answer avoids unnecessary locking, and the second avoids unnecessary suspension. – Joffrey Jul 05 '21 at 11:56
  • Actually, I read the actual implementation of `withLock` and it already has a fast path inside that doesn't suspend. I'll update my answer to remove the unecessary first check. – Joffrey Jul 05 '21 at 12:17
  • I don't know the internals of `startConnection` but considering it returns `OK` when connecting to already connected client, I assume (hope) they have some kind of fast path in there. – yaugenka Jul 05 '21 at 17:28
  • the internals of startConnection are irrelevant, as it's about not suspending the call (avoiding suspendCoroutine). Also it would avoid creating the listener object, but that's a minor detail. – Joffrey Jul 05 '21 at 21:15