Selfmade Techie

Selfmade Techie Logo

Converting Callbacks to Kotlin Coroutines

Converting Callbacks to Kotlin Coroutines

Converting Callbacks to Kotlin Coroutines

Introduction

Modern Android codebases rely heavily on the new features of the Kotlin language. Coroutines have been a central part of Kotlin’s ecosystem for quite some time now.

Many projects use Coroutines in their Services, Data Sources, and Repositories to leverage the power of writing asynchronous code and the readability of synchronous code.

If you have a modern Android codebase that relies on Courotines to keep your code simple and easier to maintain, you might encounter a third-party SDK that uses callbacks for their APIs. One of these is RevenueCat Android SDK.

The problem? -> Coroutines and callbacks do not mix together.

To keep your code nice and clean, you will need to transform the callbacks to Courutines.

Let’s begin with Converting Callbacks to Kotlin Coroutines.

Coroutines & Callbacks

Kotlin gives us 2 ways to transform something into a Coroutine(Converting Callbacks to Kotlin Coroutines):

suspendableCoroutine

suspend inline fun <T> suspendCoroutine(
    crossinline block: (Continuation<T>) -> Unit
): T

(source)

Obtains the current continuation instance inside suspend functions and suspends the currently running coroutine.

In this function both Continuation.resume and Continuation.resumeWithException can be used either synchronously in the same stack-frame where the suspension function is run or asynchronously later in the same thread or from a different thread of execution. Subsequent invocation of any resume function will produce an IllegalStateException.

suspendCancellableCoroutine

inline suspend fun <T> suspendCancellableCoroutine(crossinline block: (CancellableContinuation<T>) -> Unit): T(source)

Suspends the coroutine like suspendCoroutine, but providing a CancellableContinuation to the block. This function throws a CancellationException if the Job of the coroutine is cancelled or completed while it is suspended.

A typical use of this function is to suspend a coroutine while waiting for a result from a single-shot callback API and to return the result to the caller. For multi-shot callback APIs see callbackFlow.

suspend fun awaitCallback(): T = suspendCancellableCoroutine { continuation ->
    val callback = object : Callback { // Implementation of some callback interface
        override fun onCompleted(value: T) {
            // Resume coroutine with a value provided by the callback
            continuation.resume(value)
        }
        override fun onApiError(cause: Throwable) {
            // Resume coroutine with an exception provided by the callback
            continuation.resumeWithException(cause)
        }
    }
    // Register callback with an API
    api.register(callback)
    // Remove callback on cancellation
    continuation.invokeOnCancellation { api.unregister(callback) }
    // At this point the coroutine is suspended by suspendCancellableCoroutine until callback fires
}

The callback register/unregister methods provided by an external API must be thread-safe, because invokeOnCancellation block can be called at any time due to asynchronous nature of cancellation, even concurrently with the call of the callback.

Prompt cancellation guarantee

This function provides prompt cancellation guarantee. If the Job of the current coroutine was cancelled while this function was suspended it will not resume successfully, even if CancellableContinuation.resume was already invoked.

The cancellation of the coroutine’s job is generally asynchronous with respect to the suspended coroutine. The suspended coroutine is resumed with a call to its Continuation.resumeWith member function or to the resume extension function. However, when coroutine is resumed, it does not immediately start executing, but is passed to its CoroutineDispatcher to schedule its execution when dispatcher’s resources become available for execution. The job’s cancellation can happen before, after, and concurrently with the call to resume. In any case, prompt cancellation guarantees that the coroutine will not resume its code successfully.

If the coroutine was resumed with an exception (for example, using Continuation.resumeWithException extension function) and cancelled, then the exception thrown by the suspendCancellableCoroutine function is determined by what happened first: exceptional resume or cancellation.

Returning resources from a suspended coroutine

As a result of the prompt cancellation guarantee, when a closeable resource (like open file or a handle to another native resource) is returned from a suspended coroutine as a value, it can be lost when the coroutine is cancelled. To ensure that the resource can be properly closed in this case, the CancellableContinuation interface provides two functions.

  • invokeOnCancellation installs a handler that is called whenever a suspend coroutine is being cancelled. In addition to the example at the beginning, it can be used to ensure that a resource that was opened before the call to suspendCancellableCoroutine or in its body is closed in case of cancellation.
suspendCancellableCoroutine { continuation ->
   val resource = openResource() // Opens some resource
   continuation.invokeOnCancellation {
       resource.close() // Ensures the resource is closed on cancellation
   }
   // ...
}
  • resume(value) { … } method on a CancellableContinuation takes an optional onCancellation block. It can be used when resuming with a resource that must be closed by the code that called the corresponding suspending function.
suspendCancellableCoroutine { continuation ->
    val callback = object : Callback { // Implementation of some callback interface
        // A callback provides a reference to some closeable resource
        override fun onCompleted(resource: T) {
            // Resume coroutine with a value provided by the callback and ensure the resource is closed in case
            // when the coroutine is cancelled before the caller gets a reference to the resource.
            continuation.resume(resource) {
                resource.close() // Close the resource on cancellation
            }
        }
    // ...
}

Implementation details and custom continuation interceptors

The prompt cancellation guarantee is the result of a coordinated implementation inside suspendCancellableCoroutine function and the CoroutineDispatcher class. The coroutine dispatcher checks for the status of the Job immediately before continuing its normal execution and aborts this normal execution, calling all the corresponding cancellation handlers, if the job was cancelled.

If a custom implementation of ContinuationInterceptor is used in a coroutine’s context that does not extend CoroutineDispatcher class, then there is no prompt cancellation guarantee. A custom continuation interceptor can resume execution of a previously suspended coroutine even if its job was already cancelled.

Both APIs serve the same purpose. The only difference is that suspendCancellableCoroutine supports the cancellation of the task at hand.

Most callbacks come with the following methods:

  • a method for retrieving the result, eg. onSuccess(actionResult: ActionResult)
  • a method for catching an error eg. onError(error: Throwable)
  • (optional) a method for canceling

Implementation might look something like this:

SDK.performAction(object : ActionListener {
    override fun onSuccess(actionResult: ActionResult) {

    }

    override fun onError(error: Throwable) {

    }
})

Wrapping Callbacks to Coroutines

Wrapping generally consists of 2 steps:

  1. Create a suspend function which returns the object that you would get from the onSuccess callback method.
  2. Use suspendCoroutine as the return block with the following:
    • continuation.resume(result) for success.
    • continuation.resumeWithException(throwable) for the error.

I’ll use RevenueCat getOfferings as an example.

suspend fun Purchases.getOfferings(): Offerings {
    return suspendCoroutine { continuation ->
        getOfferings(object : ReceiveOfferingsCallback {
            override fun onError(error: PurchasesError) {
                continuation.resumeWithException(PurchasesException(error))
            }

            override fun onReceived(offerings: Offerings) {
                continuation.resume(offerings)
            }
        })
    }
}

ReceiveOfferingsCallback returns Offerings object, so our method should return the same. It also returns a PurchasesError object in case an error happens. Since this is not Throwable we need to create one.

open class PurchasesException(
    private val purchasesError: PurchasesError
) : Exception() {
    val code: PurchasesErrorCode
        get() = purchasesError.code

    override val message: String
        get() = purchasesError.message

    val underlyingErrorMessage: String?
        get() = purchasesError.underlyingErrorMessage

    override fun toString() = purchasesError.toString()
}

Conclusion

Congratulations, you have successfully wrapped your first callback into a Coroutine 🎉 ( Converting Callbacks to Kotlin Coroutines ).

Now you can use it like this:

try {
  val offerings = Purchases.getSharedInstance.getOfferings()
} catch (e: Exception) {
  Log.e("getOfferings#Error", e.message.orEmpty())
}

If you have enjoyed or found this (Converting Callbacks to Kotlin Coroutines) helpful, make sure to follow me for more content like this!

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top