Blog Infos
Author
Published
Topics
Published

Photo by Andrew Stutesman on Unsplash

 

Getting a user’s location can be a bit of hassle. Over the years, the permissions required to do this and the logic associated with it have changed quite drastically. If your application depends on getting the user’s location, we want to ensure that the experience the user has when the application requests it, is a good one. Therefore, it is crucial to handle all the edge cases and allow the user to select the option the he/she is most comfortable with.

With Jetpack Compose, the logic associated with getting the user’s location has changed a bit and it’s important to know the in’s and out’s of how it can be done.

As with most things, there is a library that can be used to handle getting permissions. It’s from Accompanist (read Google) and it can be found hereBut you are here to learn how to do things yourself, so read on. 🕵️‍♀️

Before we get into the code and the logic, it is important to understand that requesting a permission from a user is a path that can have many decision points. As such, it is best described by having states that represent the current status of the permission (approved/rejected/denied) and the current status of the operating system. The diagram below illustrates this flow:

Later on in this article, you will see how we will represent these states in variables in our code.

Save Our Souls (S.O.S)

Before we get to the logic of asking for a permission, we’ll take care of the boilerplate around it. As always, add the necessary permissions to your AndroidManifest.xml file:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Then, we’ll create a composable screen where we ask for the necessary location permissions. The first step in this screen is to check whether the user has previously granted the required permissions. This can be done with something that is not new to Jetpack Compose using the checkSelfPermission method.

val locationPermissionsAlreadyGranted = ContextCompat.checkSelfPermission(
            this,
            Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED

If the permission is not granted, we have to request it. We will use the rememberLauncherForActivityResult object to do this. This allows us in Jetpack Compose to get a result from an Activity.

val locationPermissions = arrayOf(
    Manifest.permission.ACCESS_FINE_LOCATION,
    Manifest.permission.ACCESS_COARSE_LOCATION)

val locationPermissionLauncher = rememberLauncherForActivityResult(
                contract = ActivityResultContracts.RequestMultiplePermissions(),
                onResult = { permissions ->
                    val permissionsGranted = permissions.values.reduce { acc, isPermissionGranted ->
                        acc && isPermissionGranted
                    }
             
                    if (!permissionsGranted) {
                       //Logic when the permissions were not granted by the user
                    }
                })

The arguments that we need to pass to rememberLauncherForActivityResult are:

  1. An ActivityResultContract – Specifies the input of the activity and the output
  2. onResult – A callback when the result is received

In the code snippet above, we are using a multiple permissions contract, since we are requesting several location permissions. There is also a contract for requesting just one permission, ActivityResultContracts.RequestPermission().

This piece of code doesn’t not run immediately as we need to request the permissions. To do that we use the launch method to start the activity:

locationPermissionLauncher.launch(locationPermissions)

In the case the user gave all or several of the required permissions, we can continue the logic of the application. But, if the user did not approve any of the permissions, we need to find a way to make the user understand why these permissions are necessary.

Showing A Rationale

In case the user declined the permission, but did not select the “Deny And Don’t Ask Me Again” option, we have a way of giving the user a brief explanation on why he/she should grant the required permission(s). To figure out if we should present this rationale, we use the shouldShowRequestPermissionRationale from the Activity class:

val shouldShowPermissionRationale: Boolean = shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_COARSE_LOCATION)

Once we know that we can display this explanation, there are two ways to go about it:

  1. We can present it to the user with an AlertDialog
  2. We can use the Snackbar

Presenting an alert dialog is pretty straightforward. All we have to do is make sure we describe clearly to the user why it is required to approve this permission:

@Composable
    fun ShowLocationPermissionRationale() {
        AlertDialog(
            onDismissRequest = {
               //Logic when dismiss happens
            },
        title = {
            Text("Permission Required")
                },
        text = {
            Text("You need to approve this permission in order to...")
        },
        confirmButton = {
            TextButton(onClick = {
              //Logic when user confirms to accept permissions
            }) {
                Text("Confirm")
            }
        },
        dismissButton = {
            TextButton(onClick = {
              //Logic when user denies to accept permissions
            }) {
                Text("Deny")
            }
        })
    }

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

No results found.

If we want to present the Snackbar, we need to be aware that we have to use a Scaffold container since that is the only container that supports showing a Snackbar. If we don’t use one, a Snackbar won’t appear. Below is a snippet that showcases how to do this:

val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }

Scaffold(snackbarHost = {
        SnackbarHost(hostState = snackbarHostState)
    }) { contentPadding ->
        if (shouldShowPermissionRationale) {
            LaunchedEffect(key1 = shouldShowPermissionRationale, block = {
                scope.launch {
                    val userAction = snackbarHostState.showSnackbar(
                        message ="Please authorize location permissions",
                        actionLabel = "Approve",
                        duration = SnackbarDuration.Indefinite,
                        withDismissAction = true
                    )
                    when (userAction) {
                        SnackbarResult.ActionPerformed -> {
                            //User approved to grant the permission
                            //Ask for permissions again
                        }
                        SnackbarResult.Dismissed -> {
                            //User dismissed snackbar
                        }
                    }
                }
            })
        }
}

We allowed the Snackbar itself to be dismissible using the withDismissAction attribute and listened in to the action performed by the user.

Lifecycle Observer

One thing we have glossed over is the fact that we need to make sure our permission request adheres to the composable lifecycle. Meaning, that once a user chooses his/her preferences regarding the permission request, we need the UI to adapt accordingly. If you try and put the code above inside the activity’s onCreate method, you will be surprised with the outcome, since the application will crash with the following exception:

java.lang.IllegalStateException: Launcher has not been initialized

This is because Composable functions are supposed to be side effect free. What is a side effect? According to Google’s documentation it is:

… a change to the state of the app that happens outside the scope of a composable function

So, in our use case, launching an activity for the permissions is the side effect happening here. To circumvent this scenario, we need to use one of the side effect options. Since we don’t want to ask the user for permissions without remembering what his/her choices were previously, we can’t use the general SideEffectLaunchedEffect is used for calling suspend methods inside of a Composable, which is not our use case here. So we are left with DisposableEffect. Reading the documentation, we can see that DisposableEffect can be combined with Lifecycle events, which is what we are after.

val lifecycleOwner = LocalLifecycleOwner.current
            DisposableEffect(key1 = lifecycleOwner, effect = {
                val observer = LifecycleEventObserver { _, event ->
                    if (event == Lifecycle.Event.ON_START && !locationPermissionsAlreadyGranted) {
                        locationPermissionLauncher.launch(locationPermissions)
                       }
                    }
                    lifecycleOwner.lifecycle.addObserver(observer)
                    onDispose {
                        lifecycleOwner.lifecycle.removeObserver(observer)
                    }
                }
            )

In the code snippet above, we are adding a lifecycle observer that runs only in the case of the onStart lifecycle event. We also combine it with the Boolean we have declared at the start of this section, locationPermissionsAlreadyGranted. This is so we won’t show the dialog for asking permissions if they are already granted. As with all lifecycle observers, we need to remove our observer once the composition ends. We have that logic inside DisposableEffect’s onDispose clause.

Location Not Found

The last case we need to deal with is when the user chooses the “Deny And Don’t Ask Me Again” option. When this happens, we cannot ask the user to grant the required permissions. The only way the user can revert his/her choice, is to go to the settings screen of our application and change the permissions there. Therefore, we need to direct the user to go there. To open the settings screen of our application, we need to use an intent with the action of ACTION_APPLICATION_DETAILS_SETTINGS.

Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)).also {
            startActivity(it)
        }

Taking the logic above, we can add it to our code when we know that the user has chosen to deny the permissions and not be asked again. This happens inside our request for permissions when the user has not granted the permissions and the option to show the rationale is false.

Location Confirmed

If we take everything we discussed in this article and put it inside one file, we will get the following:

class MainActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3Api::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {

            var locationPermissionsGranted by remember { mutableStateOf(areLocationPermissionsAlreadyGranted()) }
            var shouldShowPermissionRationale by remember {
                mutableStateOf(
                    shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_COARSE_LOCATION)
                )
            }

            var shouldDirectUserToApplicationSettings by remember {
                mutableStateOf(false)
            }

            var currentPermissionsStatus by remember {
                mutableStateOf(decideCurrentPermissionStatus(locationPermissionsGranted, shouldShowPermissionRationale))
            }

            val locationPermissions = arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION,
                Manifest.permission.ACCESS_COARSE_LOCATION
            )

            val locationPermissionLauncher = rememberLauncherForActivityResult(
                contract = ActivityResultContracts.RequestMultiplePermissions(),
                onResult = { permissions ->
                    locationPermissionsGranted = permissions.values.reduce { acc, isPermissionGranted ->
                        acc && isPermissionGranted
                    }

                    if (!locationPermissionsGranted) {
                        shouldShowPermissionRationale =
                            shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_COARSE_LOCATION)
                    }
                    shouldDirectUserToApplicationSettings = !shouldShowPermissionRationale && !locationPermissionsGranted
                    currentPermissionsStatus = decideCurrentPermissionStatus(locationPermissionsGranted, shouldShowPermissionRationale)
                })

            val lifecycleOwner = LocalLifecycleOwner.current
            DisposableEffect(key1 = lifecycleOwner, effect = {
                val observer = LifecycleEventObserver { _, event ->
                    if (event == Lifecycle.Event.ON_START &&
                        !locationPermissionsGranted &&
                        !shouldShowPermissionRationale) {
                        locationPermissionLauncher.launch(locationPermissions)
                    }
                }
                lifecycleOwner.lifecycle.addObserver(observer)
                onDispose {
                    lifecycleOwner.lifecycle.removeObserver(observer)
                    }
                }
            )

            val scope = rememberCoroutineScope()
            val snackbarHostState = remember { SnackbarHostState() }

            LocationPermissionsTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Scaffold(snackbarHost = {
                        SnackbarHost(hostState = snackbarHostState)
                    }) { contentPadding ->
                        Column(modifier = Modifier.fillMaxSize(),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally){
                            Text(modifier = Modifier
                                .padding(contentPadding)
                                .fillMaxWidth(),
                                text = "Location Permissions",
                                textAlign = TextAlign.Center)
                            Spacer(modifier = Modifier.padding(20.dp))
                            Text(modifier = Modifier
                                .padding(contentPadding)
                                .fillMaxWidth(),
                                text = "Current Permission Status: $currentPermissionsStatus",
                                textAlign = TextAlign.Center,
                                fontWeight = FontWeight.Bold
                            )
                        }
                        if (shouldShowPermissionRationale) {
                            LaunchedEffect(Unit) {
                                scope.launch {
                                    val userAction = snackbarHostState.showSnackbar(
                                        message ="Please authorize location permissions",
                                        actionLabel = "Approve",
                                        duration = SnackbarDuration.Indefinite,
                                        withDismissAction = true
                                    )
                                    when (userAction) {
                                        SnackbarResult.ActionPerformed -> {
                                            shouldShowPermissionRationale = false
                                            locationPermissionLauncher.launch(locationPermissions)
                                        }
                                        SnackbarResult.Dismissed -> {
                                            shouldShowPermissionRationale = false
                                        }
                                    }
                                }
                            }
                        }
                        if (shouldDirectUserToApplicationSettings) {
                            openApplicationSettings()
                        }
                    }
                }
            }
        }
    }

    private fun areLocationPermissionsAlreadyGranted(): Boolean {
        return ContextCompat.checkSelfPermission(
            this,
            Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
    }

    private fun openApplicationSettings() {
        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)).also {
            startActivity(it)
        }
    }

    private fun decideCurrentPermissionStatus(locationPermissionsGranted: Boolean,
                                              shouldShowPermissionRationale: Boolean): String {
        return if (locationPermissionsGranted) "Granted"
        else if (shouldShowPermissionRationale) "Rejected"
        else "Denied"
    }
}

And this is how it looks like:

I have put all the logic in one file just for the purpose of this article. It is no by no means the most esthetic and correct approach to handling the logic with requesting permissions. You could easily refactor out the logic variables associated with holding the different state of the request for permissions to a view model class that is attached to this screen.

You can see all of the code described in this article by going to this project:

And if you would like to read more of my articles, you can go view them below:

I have also used this logic in an application that you can try out here.

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
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