Blog Infos
Author
Published
Topics
, ,
Published

Handling results from other Activities was always a tedious task: you need to override callbacks in the Activity/Fragment, and you need to manage the different request codes, and when you get a result, you need to parse it and this was never easy*.

* will the returned Uri be in intent.data or passed in the extras (looking at you RingtoneManager.EXTRA_RINGTONE_PICKED_URI)? what’s the key of the returned data inside the extras and what’s its type?…

To solve the above issues, the Android team introduced the androidx’s Activity Result API, it offers a nicer API, and it’s easily extendable by writing custom Contracts, but it still requires doing most of the work on the UI layer, which means lots of going back and forth between the View and the ViewModel for simple tasks.

This post is the first of two posts where we’ll explore how we can optimize the API, by converting it to suspendable functions, and allowing it to be consumed by the ViewModel instead of the UI layer.
On this first part, we’ll use permissions as an example, for this We’ll start by showing an example on how the Activity Result API can be used to handle them, and then we’ll try to write the new suspendable API together, and see how it can improve the readability of our code.
The code of the example can be found in this repo.

Part 2 is available here.

Using the Activity Result API is quite simple, you just need to register an ActivityResultContract with your Activity/Fragment, while offering a callback that will be called when the result is delivered:

private val locationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
viewModel.onPermissionGranted(
granted = it,
shouldShowRationale = shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
)
}
view raw Fragment.kt hosted with ❤ by GitHub

The registration will return an ActivityResultLauncher which you will use whenever you need to request the result

locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
view raw Fragment.kt hosted with ❤ by GitHub

But while it’s simple now, it becomes a bit complicated when we try to hook everything with the ViewModel, as it would require multiple two-way communication steps for something simple as the permission:

class ViewModel {
private val _events = Channel<Event>(capacity = Channel.BUFFERED)
val events = _events.receiveAsFlow()
.whenAtLeast(Lifecycle.Started)
private val locationPermissionGranted = MutableStateFlow(false)
fun onPermissionGranted(granted: Boolean, shouldShowRationale: Boolean) {
if (!granted) {
if (shouldShowRationale) {
_events.trySend(Event.ShowPermissionRationale)
} else {
_events.trySend(Event.ShowPermissionSnackBar)
}
}
if (granted || !shouldShowRationale) {
locationPermissionGranted.value = granted
}
}
sealed class Event {
object RequestLocationPermission : Event()
object ShowPermissionRationale : Event()
object ShowPermissionSnackBar : Event()
}
}
view raw ViewModel.kt hosted with ❤ by GitHub
class Fragment {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = FragmentPermissionBinding.bind(view)
binding.button.setOnClickListener {
viewModel.onButtonClicked()
}
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
observeEvents(binding)
}
}
private fun CoroutineScope.observeEvents(binding: FragmentPermissionBinding) {
viewModel.events
.onEach {
when (it) {
PermissionRegularViewModel.Event.RequestLocationPermission -> {
locationPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
PermissionRegularViewModel.Event.ShowPermissionRationale -> {
// UI for the rationale
}
PermissionRegularViewModel.Event.ShowPermissionSnackBar -> {
// UI for permanent denial
}
}
}
.launchIn(this)
}
}
view raw Fragment.kt hosted with ❤ by GitHub

This is all while omitting some necessary parts, especially dealing with configuration changes and process-death.
For a full example, check the following sample fragment.

I feel like with this code, the View is doing so much, and we can’t easily reuse this code if we need to request the same permission in multiple parts of the app.

On this part, we’ll try to move the logic of Activity Result API to another layer, to allow it to be consumed by the ViewModel.
Our goal is to have something similar to this by the end:

class PermissionManager {
fun hasPermission(permission: String): Boolean
suspend fun requestPermission(permission: String): PermissionStatus
suspend fun requestPermissions(vararg permission: String): Map<String, PermissionStatus>
}
sealed class PermissionStatus
object PermissionGranted : PermissionStatus()
data class PermissionDenied(val shouldShowRationale: Boolean) : PermissionStatus()

Let’s get started, since the Activity Result API needs a reference to the Activity or the Fragment, the first thing we’ll need, is a reference to the current Activity, we can use the function registerActivityLifecycleCallbacks to listen to Activity lifecycle changes, and keep a track of the latest one, a short example on how we can achieve this:

class ActivityProvider : Application.ActivityLifecycleCallbacks {
private var _currentActivity = WeakReference<ComponentActivity>(null)
val currentActivity
get() = _currentActivity.get()
fun init(application: Application) = application.registerActivityLifecycleCallbacks(this)
override fun onActivityCreated(activity: Activity, bundle: Bundle?) {}
override fun onActivityStarted(activity: Activity) {
(activity as? ComponentActivity)?.let {
_currentActivity = WeakReference(it)
}
}
override fun onActivityResumed(activity: Activity) {
(activity as? ComponentActivity)?.let {
_currentActivity = WeakReference(it)
}
}
...
}

This class needs to be a singleton, and initiated in the Application’s onCreate.
With this in hand, we can now “naively” write something like this easily:

class PermissionManager(
private val context: Context,
private val activityProvider: ActivityProvider
) {
private val keyIncrement = AtomicInteger(0)
fun hasPermission(permission: String): Boolean {
return context.checkSelfPermission(permission) == PERMISSION_GRANTED
}
suspend fun requestPermission(permission: String): PermissionStatus {
return requestPermissions(permission)[permission] ?: error("permission result is empty")
}
suspend fun requestPermissions(vararg permissions: String): Map<String, PermissionStatus> {
val currentActivity = activityProvider.currentActivity ?: return permissions.associateWith {
PermissionDenied(false)
}
return suspendCancellableCoroutine { continuation ->
val launcher = currentActivity.activityResultRegistry.register(
"permission_${keyIncrement.getAndIncrement()}",
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
continuation.resume(permissions.associateWith {
if (result[it] == true) {
PermissionGranted
} else {
val shouldShowRationale =
currentActivity.shouldShowRequestPermissionRationale(it)
PermissionDenied(shouldShowRationale)
}
})
}
launcher.launch(permissions)
continuation.invokeOnCancellation {
launcher.unregister()
}
}
}
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

Before testing our code, let’s take a moment to check what it does:

  • We use a suspendCancellableCoroutine to convert the Activity result to a suspendable function.
  • We can’t register our Contract directly using the function registerForActivityResult, as it requires to be called before onCreate, instead we use the activityResultRegistry directly, and here we just need to handle the unregistering ourself, so we use the finally block to make sure we are unregistering in either a cancellation or completion.

Now, let’s test the code:

class MyViewModel(private val permissionManager: PermissionManager): ViewModel() {
fun onButtonClicked() {
viewModelScope.launch {
val permissionStatus = permissionManager.requestPermission(Manifest.permission.ACCESS_FINE_LOCATION)
// handle the result
}
}
}
view raw MyViewModel.kt hosted with ❤ by GitHub

When testing this, we’ll see that it works well, until you rotate your phone, or a process-death occurs, when we’ll leak the activity, and also we won’t be able to recover to continue handling the result.

This happens because we are capturing the current Activity in a variable, this variable stays available for the whole function’s lifetime, which is longer than the Activity’s lifecycle. To solve this, we need a way to listen to the Activity changes, to be able to cancel the previous block and unregister the Contract, then re-register with the new Activity, so we need to change our ActivityProvider’s implementation to offer a Flow instead of a single variable, which can bee easily done using MutableStateFlow, for full code check this.
With those changes, we can now use Flow’s mapLatest operator to handle registering our Contract, and unregistering it when the block is cancelled, since it would occur everytime the Activity wach changed, which would give something like this:

class PermissionManager(
private val context: Context,
private val activityProvider: ActivityProvider
) {
...
suspend fun requestPermissions(vararg permissions: String): Map<String, PermissionStatus> {
val key = "permission_${keyIncrement.getAndIncrement()}"
var isLaunched = false
return activityProvider.activityFlow
.mapLatest { currentActivity ->
var launcher: ActivityResultLauncher<Array<out String>>? = null
try {
suspendCancellableCoroutine<Map<String, PermissionStatus>> { continuation ->
launcher = currentActivity.activityResultRegistry.register(
key,
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
continuation.resume(permissions.associateWith {
if (result[it] == true) {
PermissionGranted
} else {
val shouldShowRationale =
currentActivity.shouldShowRequestPermissionRationale(it)
PermissionDenied(shouldShowRationale)
}
})
}
if (!isLaunched) {
launcher!!.launch(permissions)
isLaunched = true
}
}
} finally {
launcher?.unregister()
}
}
.first()
}
}

Let’s examine what the code does:

  • We capture the value of the key outside of the Flow, to make sure we keep using the same value for other Activities.
  • We have a variable isLaunched to let us know that the ActivityResultLauncher has been already launched, to avoid re-submitting the Intent or permission request.
  • the suspendable funtion passed to mapLatest will be cancelled each time the upstream Flow emits a new value, so it does exactly what we need, it suspends waiting for the Activity Result, but if the Activity was changed before, it cancels the current block, and starts a new one with the new Activity.

When testing the new code, it works pretty well on screen rotations, we can continue the Flow from where we left, great.
Except that it has an issue, if a process-death occurs, or the Activity was destroyed to release memory (or when “Don’t keep activities” option is enabled), we can’t recover even if our ViewModel uses SavedStateHandle to restore its state, since the viewModelScope will be cancelled, and our function will lose its state, which takes us to the last part of this article, how we can use the Activity’s SavedStateRegistry to save our state, and make sure that even when we make a second call to requestPermissions it will continue from where it left.

ComponentActivity from androidx has a component called SavedStateRegistry that allows plugging in custom components to save additional data and retrieve it later, and this is exactly what we need here, since we can’t use the Activity’s callbacks, we’ll use it to keep track of the last key we used to register our Contract, to be able to intercept the Activity Result even after process-death:

class PermissionManager(
private val context: Context,
private val activityProvider: ActivityProvider
) {
...
suspend fun requestPermissions(vararg permissions: String): Map<String, PermissionStatus> {
var isLaunched = false
// Restore or generate a new key
val key = activityProvider.currentActivity?.let { activity ->
val savedBundle = activity.savedStateRegistry.consumeRestoredStateForKey(SAVED_STATE_REGISTRY_KEY)
if (savedBundle?.getString(PENDING_PERMISSIONS_KEY) == permissions.joinToString(",")) {
isLaunched = true
generateKey(savedBundle.getInt(LAST_INCREMENT_KEY))
} else {
generateKey(keyIncrement.getAndIncrement())
}
} ?: return permissions.associateWith {
if (hasPermission(it)) PermissionGranted else PermissionDenied(shouldShowRationale = false)
}
// Keep track of the pending permissions
pendingPermission = permissions.joinToString(",")
return activityProvider.activityFlow
.mapLatest { currentActivity ->
if (!isLaunched) {
// If it's the first call, then attach our SavedStateProvider
prepareSavedData(currentActivity)
}
var launcher: ActivityResultLauncher<Array<out String>>? = null
try {
suspendCancellableCoroutine<Map<String, PermissionStatus>> { continuation ->
launcher = currentActivity.activityResultRegistry.register(
key,
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
// Clear the saved data
pendingPermission = null
clearSavedStateData(currentActivity)
continuation.resume(permissions.associateWith {
if (result[it] == true) {
PermissionGranted
} else {
val shouldShowRationale = currentActivity.shouldShowRequestPermissionRationale(it)
PermissionDenied(shouldShowRationale)
}
})
}
if (!isLaunched) {
launcher!!.launch(permissions)
isLaunched = true
}
}
} finally {
launcher?.unregister()
}
}
.first()
}
private fun prepareSavedData(currentActivity: ComponentActivity) {
currentActivity.savedStateRegistry.registerSavedStateProvider(
SAVED_STATE_REGISTRY_KEY
) {
bundleOf(
PENDING_PERMISSIONS_KEY to pendingPermission,
LAST_INCREMENT_KEY to keyIncrement.get() - 1
)
}
}
private fun clearSavedStateData(currentActivity: ComponentActivity) {
currentActivity.savedStateRegistry.unregisterSavedStateProvider(
SAVED_STATE_REGISTRY_KEY
)
// Delete the data by consuming it
currentActivity.savedStateRegistry.consumeRestoredStateForKey(
SAVED_STATE_REGISTRY_KEY
)
}
private fun generateKey(increment: Int) = "permission_$increment"
}

The code seems complicated, but it differs from the previous step in only the fact that it tries to restore the last used key from the SavedStateRegistry, and also it registers our SavedStateProvider to save the state when needed.
If we test the code now, using a ViewModel that uses SavedStateHandle for saving/restoring its state correctly, we’ll see that we continue to receive the Activity Result correctly even after a process-death.

Being able to use a suspendable function like this here, makes it way easier to extract our logic into a reusable components, for example if your app requests the permission from multiple areas, and uses the same logic for showing the rationale or handling a pemanent denial, you’ll be able to create a component or a usecase that handles this for you, without having to repeat it on each Fragment/Activity.
For example a class like this:

class LocationPermissionController(
private val permissionManager: PermissionManager,
private val activityProvider: ActivityProvider
) {
suspend fun requestLocationPermission(): Boolean {
if (permissionManager.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) return true
val permissionStatus =
permissionManager.requestPermission(Manifest.permission.ACCESS_FINE_LOCATION)
return when (permissionStatus) {
is PermissionDenied -> {
if (permissionStatus.shouldShowRationale) {
if (showRationale()) requestLocationPermission() else false
} else {
showDenialSnackBar()
false
}
}
PermissionGranted -> true
}
}
private suspend fun showRationale(): Boolean {
// Show a dialog with to explain the need, and handle re-requesting the permission
}
private fun showDenialSnackBar() {
// Show a Snackbar to let the user know about the denial
}
}

This component is self contained, and allows to handle the whole permission request Flow, and can be reused on every screen easily.
A full example can be found here.

To sum up, the callback aspect of the new Result Activity API allows it to be converted to suspendable functions relatively easy, instead of needing an intermediate Fragment or Activity for handling the callbacks (as many libraries do), and this conversion made it easier to extract pieces of the logic to their own component. But the code is not tested against all scenarios, especially when the app has multiple activities, so we’ll try to check this in the next part, and we’ll try to generalize what we did with permissions so far for all the Activity Result Contracts.
Please let me know in the comments if you have any remarks about this solution, thanks for your time.

Originally published at https://dev.to on November 16, 2021.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
This tutorial is the second part of the series. It’ll be focussed on developing…
READ MORE
blog
We recently faced a problem with our application getting updated and reaching slowly to…
READ MORE
blog
A few weeks ago I started with a simple question — how to work…
READ MORE
blog
One of the main functions of a mobile phone was to store contacts information.…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu