Suspension not detention: launching activities without callback headaches

On Android you need to start activities on a regular basis. Those can be system-provided ones or your own. But no matter which, those activities often depend on each other. And to wire everything up you might need to get deep into callback hell.

by Marc Neureiter

There’s a solution for that: wrapping activity launch code in suspending functions. That dramatically increases readability and maintainability of your code. Let me show you!

 

How it looks

 

In my opinion, business logic in code should be so simple, non-developers can (almost) read it:

 

suspend fun runTakePictureFlow() {

    if (!permissionManager.check(CAMERA)) {

       if (permissionManager.request(CAMERA) != true) {

           appSettingsRunner()

       }

    }




    if (!permissionManager.check(CAMERA)) {

       setMessage("Couldn't obtain camera permissions!")

       return

    }




    takePictureRunner()

        .let(::setPicture)




    setMessage("Picture was taken!")

}

 

So what this code does is to achieve the following flow:

 

  1. Check for camera permissions.
  2. If the check is negative, request camera permissions from the user.
  3. If that still didn’t work, navigate to the app settings so the user can grant the permission manually.
  4. After coming back from the app settings and if the user failed to enable the camera permission, show an error message and cancel the flow.
  5. In all other cases, take a picture and show it.

 

As you can see, this is a suspending function: its flow is asynchronous.  permissionManager.request(…), appSettingsRunner() and takePictureRunner() are all suspending function calls as well. That means: they return whenever the associated task is finished. Have a look at my GitHub repo to check out the full solution.

 

Initially, I wanted to include an example of how complicated that same behavior would be to implement by traditional means, but I feared that would just be too boring. So let me draw your attention to the secret sauce under the hood right away.

 

How it works

 

To be honest with you, there’s not so much about it. There’s one centerpiece of bringing any API that is based on callbacks into the holy lands of coroutines, and that is suspendCoroutine. In order to start any activity you can ever imagine, use that following simple wrapper:

 

suspend fun <I, O> runActivityForResult(

    currentActivity: ComponentActivity,

    contract: ActivityResultContract<I, O>,

    input: I

): O? {

    var activityResultLauncher: ActivityResultLauncher<I>? = null

    val key = UUID.randomUUID().toString()

    suspendCoroutine { continuation ->

        activityResultLauncher =

            currentActivity.activityResultRegistry.register(key, contract, continuation::resume)

                .also { it.launch(input) }

    }

}

 

This is a simplified version of the final one you can look at here. If you want to use it, do so happily, but better refer to the full version that covers some edge cases and has better documentation!

 

All this function does is suspending the current coroutine until the activity is finished. In essence, the suspending function only returns when the activity is finished. And this is what enables the fluent, easy to understand code I showed you in the beginning.

 

There’s still one piece missing here: how does the implementation of one of such suspending functions look like, for instance permissionManager.request(…)?

 

class PermissionManager(private val activity: ComponentActivity) {



    fun check(permission: String) =

          activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED



    suspend fun request(permission: String): Boolean? =

        runActivityForResult(activity, ActivityResultContracts.RequestPermission(), permission)

}

In the last line of the class you can finally see the call to runActivityForResult and it’s not overwhelming either: wrapping an activity that way is in many cases just a one-liner.

 

View models and Jetpack Compose

 

You can already use runActivityForResult from any activity or fragment, if you want to. But usually, you would like to have the business logic in a view model instead. So how to make runActivityForResult accessible to business logic in the view model?

 

I created a nice wrapper for runActivityForResult that can be passed from a view to its view model:

 

class ActivityRunner<I, O>(

    private val currentActivity: ComponentActivity,

    private val contract: ActivityResultContract<I, O>,

) : ActivityRunner<I, O> {

    override suspend operator fun invoke(input: I): O? {

        return runActivityForResult(currentActivity, contract, input)

    }

}

 

You can instantiate this class and pass it to any view model like that:

 

val requestPermissionRunner = ActivityRunner(LocalContext.current as ComponentActivity, ActivityResultContracts.RequestPermission())



LaunchedEffect(Unit) {

    viewModel.init(requestPermissionRunner)

}

Now you can use this runner in the view model as demonstrated in the beginning.

 

But to make it even more convenient and to avoid creating an extra instance on each recomposition, you should definitely wrap this in a remember function like this:

 

@Composable

fun rememberRequestPermissionRunner(): ActivityRunner<String, Boolean> {

    val currentActivity = LocalContext.current as ComponentActivity

    return remember { ActivityRunner(currentActivity, ActivityResultContracts.RequestPermission()) }

}

You can look up the implementation of the other suspending functions here.

 

Thoughts

 

  1. Note that this only works as long as the calling activity is not destroyed yet. Otherwise there’s really no way we can receive results anymore. Therefore the full version of runActivityForResult has a check for the activity’s state and changes thereof. Use this function only for waiting for activities to finish or return a result, but not for navigating off to a different activity!
  2. There’s a best practice to keep view models free from platform-specific types like Intent, ActivityResultContract or ActivityResultLauncher and I recommend you to follow that. You can see an example of mapping between domain and platform code down below.
  3. You need to write your own wrappers, but all in all you end up with less and easier-to-understand and maintainable code.

 

Further optimization

 

With what I’ve shown here you’re able to model any user flow across activities imaginable and with very simple and malleable code, already.

 

There are two iterations of the solution that will be necessary as use cases expand:

 

1. Reduce boilerplate code

 

For each use case you might need to add another runner. That can add up:

 

val pickVisualMediaRunner = rememberPickVisualMediaRunner()

val requestPermissionRunner = rememberRequestPermissionRunner()

val takePictureRunner = rememberTakePictureRunner()

val permissionManager = rememberPermissionManager()

val appSettingsRunner = rememberAppSettingsRunner()



LaunchedEffect(Unit) {

    viewModel.init(

        pickVisualMediaRunner,

        requestPermissionRunner,

        takePictureRunner,

        permissionManager,

        appSettingsRunner

    )

}

 

That’s a lot of boilerplate code. You can remedy the situation by placing all of those in a single provider for all kinds of runners. You can have a look at the finished solution here.

 

2. Introduce transform capability

 

Android’s ActivityResultContract already has the capability of transforming input and output data you exchange with other activities. But there’s two problems with that:

 

  1. Often the input and output types are platform-bound, and usually we don’t want to have them in business-related code
  2. If you want to launch your custom or any third-party activities, you might need to write your own ActivityResultContract and you need a custom class that implements a contract for that

 

By expanding ActivityRunner‘s capabilities, we can have convenient transformation of input and output data by passing transformation blocks into the constructor, all without the need to create a new contract class:

 

typealias PickVisualMediaRunner = ActivityRunner<PickVisualMediaType, Uri>



@Composable

fun rememberPickVisualMediaRunner(): PickVisualMediaRunner {

   val currentActivity = LocalContext.current as ComponentActivity

   return remember {

       TransformingActivityRunner(

           currentActivity = currentActivity,

           contract = ActivityResultContracts.PickVisualMedia(),

           transformInput = {

               val mediaType = when (it) {

                   PickVisualMediaType.ALL -> ActivityResultContracts.PickVisualMedia.ImageAndVideo

                   PickVisualMediaType.IMAGE -> ActivityResultContracts.PickVisualMedia.ImageOnly

                   PickVisualMediaType.VIDEO -> ActivityResultContracts.PickVisualMedia.VideoOnly

               }

               PickVisualMediaRequest.Builder()

                   .setMediaType(mediaType)

                   .build()

           },

           transformOutput = {

               // This is just to show the possibility of an exception being thrown. In reality a nullable result would be preferable.

               it ?: error("No image was picked!")

           }

       )

   }

}

 

You can have a look at all the implementations here.

 

All in all, by using that approach the platform implementation details are conveniently encapsulated in a reusable way; business logic and implementation details are nicely separated, producing simple, malleable code.

 

Happy coding!