The Android Activity Result API is a clean way to return data from child components, until you try to consume it from a ViewModel and the lifecycle code starts tangling itself in knots. Coroutines fix most of that, if you wrap the API correctly.
This piece walks through a pattern that generalizes an earlier approach for handling permissions, letting any ActivityResultContract be consumed with a single suspend function while surviving process death. The key is moving the contract into the function itself and using SavedStateHandle to track pending requests.
The shape of a clean wrapper
The core signature pulls the contract inside the suspend function so the wrapper stays reusable across all contract types:
Suspend fun <I, O, C : ActivityResultContract<I, O>> requestResult(contract: C, input: I): O?
The return type matches whatever the contract defines. SavedStateHandle is what keeps the wrapper honest across configuration changes and process death, remembering whether a result is still pending.
The interesting decision is how the wrapper identifies a pending action after process death. Three approaches exist:
| Approach | What it compares | Risk |
|---|---|---|
| Simple boolean | Nothing | Wrong call site gets the result if death hits between request and response |
| Contract class only | Contract class name | Returns wrong result if two requests use the same contract with different inputs |
| Contract class + input | Both class and input value | Safest, but comparing generic input values is messy and error-prone |
The pattern picks the middle option—comparing only the contract class—and relies on SavedStateHandle to keep state intact. For most apps this works fine. If your feature requests the same contract type with different inputs, you'll want to add input comparison.
How to use the wrapper
The library provides two paths. The low-level API accepts any contract and input directly. Extension functions wrap the built-in ActivityResultContracts so requesting permissions or opening documents becomes a one-liner instead of boilerplate around registerForActivityResult.
The sample app demonstrates the wrapper with permissions, common system contracts, and a custom ActivityResultContract. That last example matters: most teams ship at least one in-house contract for QR scanning, document pickers, or deep-link handling.
What it means for you
Coroutines let you collapse lifecycle plumbing into a single suspending call. The win compounds once a feature has several contracts in flight.
Who should care:
- Developers refactoring legacy features** with scattered launcher fields and callback booleans will see the biggest readability gains
- Teams writing unit tests** win immediately since suspend functions test with TestCoroutineDispatcher instead of Robolectric
- Library maintainers** shipping custom contracts can expose a clean suspend interface instead of asking consumers to manage launchers
What to watch next
Add a unit test that simulates saved-state restore after a permissions request. It will confirm the contract-class comparison handles your scenarios. If you have multiple pending requests of the same contract type with different inputs, extend the comparison to include the input value.
Key takeaways
- Moving the ActivityResultContract into the suspend function makes the wrapper reusable for any contract type
- SavedStateHandle is what keeps the wrapper honest across process death; do not skip it
- Comparing contract class names is a reasonable default, but add input comparison if your app sends different inputs to the same contract
- The pattern collapses multiple launchers into a few suspend calls, which simplifies tests and ViewModel code
Frequently asked questions
What problem does wrapping Activity Result in coroutines solve?
It removes the need for registerForActivityResult launchers and callbacks inside ViewModels, replacing them with a single suspending function call that survives lifecycle events.
How does process death affect this wrapper?
Without SavedStateHandle, the wrapper can lose track of a pending request and return results to the wrong call site. The pattern relies on SavedStateHandle to keep state accurate across configuration changes and death.
Can the wrapper handle custom ActivityResultContracts?
Yes, because the contract is passed as a parameter. The sample app demonstrates using the wrapper with custom contracts like QR scanners.
Why not compare the input as well as the contract class?
Comparing both is more correct but tricky with generic types, since equality on user-defined inputs can be unreliable. Contract-class comparison is a pragmatic compromise that works for most scenarios.
Is the wrapper available as a library?
The code is published in the same repository as the sample app and can be integrated directly or used as a reference implementation.