Detecting when an Android permission can only be granted via Settings

I was surprised to discover how easy it is, even when following the official Android permissions guide, to end up with a button that does nothing.

Setting the stage: how it works on iOS

On iOS CLAuthorizationStatus has 6 possible states to indicate what level of access a user has granted to their location:

public enum CLAuthorizationStatus {
case notDetermined
case restricted
case denied
static var authorized: CLAuthorizationStatus // deprecated
case authorizedAlways
case authorizedWhenInUse
}

The denied and notDetermined cases are crucial because they allow us to differentiate between a user who has been asked for access and rejected it, and a user who has not yet been asked. We know the system shows the permission request UI when CLAuthorizationStatus is notDetermined, but does nothing if the status is denied.

As a result, we can create buttons and interfaces that respond appropriately to each state. For example:

.notDetermined
We need location authorization
[Request authorization]

.denied
We need location authorization
[Open Settings]

Taking the stage: how it works on Android

Consider my surprise when I learned the equivalent Android type is:

public sealed interface PermissionStatus {
    public object Granted : PermissionStatus
    public data class Denied(
        val shouldShowRationale: Boolean
    ) : PermissionStatus
}

This type is defined by Google's Accompanist which builds on top of existing Android APIs for easier access from Jetpack Compose. In truth, Android requires separate APIs to determine whether a permission is granted or denied and if an additional rationale may be shown or not.

Android does not have an official API for detecting the difference between notDetermined and denied. As a result, there’s no standard method to send users to the settings app if they mistakenly reject a permission or want to grant it later. If implemented the official Android way, they will get a button that simply does nothing.

The maintainer of Accompanist admits as much and agrees it's terrible UX. Luckily someone in the comments provided a partial solution. It's partial because it only supports asking for a single permission, but precise location access on Android requires two separate permissions: android.permission.ACCESS_COARSE_LOCATION and android.permission.ACCESS_FINE_LOCATION. It works by remembering the original state of the permission and directing the user to Settings if the permission changed from Denied(shouldShowRationale=true) to Denied(shouldShowRationale=false).

Intermission: understanding how permissions change

Before we can understand the final solution we need to understand how permissions change as the user rejects all or partial permissions:

Rejecting all permissions

The flow for rejecting all permissions is the same as for rejecting a single permission, meaning we can use the same approach as the original implementation but looking at all the requested permissions instead.

Initial state

The state of permissions when the user first launches the app or has previously rejected all permissions.

Permission PermissionStatus
ACCESS_COARSE_LOCATION Denied(shouldShowRationale=false)
ACCESS_FINE_LOCATION Denied(shouldShowRationale=false)

First rejection

After the user first rejects permissions Android gives us the opportunity to provide an additional rationale for why it's necessary:

Permission PermissionStatus
ACCESS_COARSE_LOCATION Denied(shouldShowRationale=true)
ACCESS_FINE_LOCATION Denied(shouldShowRationale=true)

Second rejection

Because the user rejects the permission again we're no longer allowed to show a rationale. The system will no longer show system UI when asked.

Permission PermissionStatus
ACCESS_COARSE_LOCATION Denied(shouldShowRationale=false)
ACCESS_FINE_LOCATION Denied(shouldShowRationale=false)

The important state change

Because the permission state changes from Denied(shouldShowRationale=true) to Denied(shouldShowRationale=false), we know that the user has denied us for the final time and can instead open the Settings app.

In code:

currentState is PermissionStatus.Denied && currentState.shouldShowRationale &&
newState is PermissionStatus.Denied && !newState.shouldShowRationale

Rejecting partial permissions:

The flow for partial permissions is slightly different:

Initial state

The state of permissions when the user first launches the app or has previously rejected all permissions.

Permission PermissionStatus
ACCESS_COARSE_LOCATION Denied(shouldShowRationale=false)
ACCESS_FINE_LOCATION Denied(shouldShowRationale=false)

Partial approval

The user allowed coarse location, but not fine.

Permission PermissionStatus
ACCESS_COARSE_LOCATION Granted
ACCESS_FINE_LOCATION Denied(shouldShowRationale=false)

Second partial approval

The user is asked to change location access from coarse to fine but rejects it.

Permission PermissionStatus
ACCESS_COARSE_LOCATION Granted
ACCESS_FINE_LOCATION Denied(shouldShowRationale=false)

The important state change

In this case we see that with the first and second partial approvals the permission state doesn't change at all. We can detect this specific state by making sure that at least one permission was granted and the permission state didn't change:

if (newPermissionStates.any { !it.value.isGranted } && currentPermissionsStates == newPermissionStates)

Unfortunately, there is still an issue with this approach. After rejecting the change from approximate to precise location, the system UI dismisses and the Settings app opens immediately. Ideally this would happen the next time they tap the button, like it does when all permissions are rejected. I decided that this is good enough for me. It should be relatively rare and I've already spend more time on this than I wanted.

Applause: the full solution

package io.ipinfo.android.extensions

import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.PackageManager
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale

// Extend Accompanist to detect when a permission needs to be granted through the Settings app.
// Inspired by: https://github.com/google/accompanist/issues/1363#issuecomment-1326516265

/** Find the closest Activity in a given Context. */
internal fun Context.findActivity(): Activity {
    var context = this
    while (context is ContextWrapper) {
        if (context is Activity) return context
        context = context.baseContext
    }
    throw IllegalStateException("Permissions should be called in the context of an Activity")
}

internal fun Context.checkPermission(permission: String): Boolean {
    return ContextCompat.checkSelfPermission(this, permission) ==
        PackageManager.PERMISSION_GRANTED
}

internal fun Activity.shouldShowRationale(permission: String): Boolean {
    return ActivityCompat.shouldShowRequestPermissionRationale(this, permission)
}

private val TAG = "ExtendedAccompanist"

/** `true` if a rationale should be presented to the user. */
@ExperimentalPermissionsApi
internal val PermissionStatus.shouldShowRationale: Boolean
    get() =
            when (this) {
                PermissionStatus.Granted -> false
                is PermissionStatus.Denied -> shouldShowRationale
            }

@ExperimentalPermissionsApi
@Composable
fun rememberPermissionState(
        permission: String,
        onCannotRequestPermission: () -> Unit = {},
        onPermissionResult: (Boolean) -> Unit = {},
): ExtendedPermissionState {
    val context = LocalContext.current

    var currentShouldShowRationale by remember {
        mutableStateOf(context.findActivity().shouldShowRationale(permission))
    }

    var atDoubleDenialForPermission by remember { mutableStateOf(false) }

    val mutablePermissionState =
            rememberPermissionState(permission) { isGranted ->
                if (!isGranted) {
                    val updatedShouldShowRationale =
                            context.findActivity().shouldShowRationale(permission)
                    if (!currentShouldShowRationale && !updatedShouldShowRationale)
                            onCannotRequestPermission()
                    else if (currentShouldShowRationale && !updatedShouldShowRationale)
                            atDoubleDenialForPermission = false
                }
                onPermissionResult(isGranted)
            }

    return remember(permission) {
        ExtendedPermissionState(
                permission = permission,
                mutablePermissionState = mutablePermissionState,
                onCannotRequestPermission = onCannotRequestPermission,
                atDoubleDenial = atDoubleDenialForPermission,
                onLaunchedPermissionRequest = { currentShouldShowRationale = it }
        )
    }
}

@OptIn(ExperimentalPermissionsApi::class)
private fun permissionStates(context: Context, permissions: List<String>): Map<String, PermissionStatus> {
    return permissions.associate {
        if (context.checkPermission(it)) {
            it to PermissionStatus.Granted
        } else {
            it to PermissionStatus.Denied(
                shouldShowRationale = context.findActivity().shouldShowRationale(it)
            )
        }
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun rememberMultiplePermissionsState(
        permissions: List<String>,
        onPermissionsResult: (Map<String, Boolean>) -> Unit = {},
        onCannotRequestPermission: () -> Unit = {}
): ExtendedMultiplePermissionState {
    val context = LocalContext.current
    var currentPermissionsStates by remember {
        mutableStateOf(permissionStates(context, permissions))
    }

    var atDoubleDenialForPermission by remember { mutableStateOf(false) }

    val mutablePermissionState =
            rememberMultiplePermissionsState(
                    permissions,
                    onPermissionsResult = { newPermissions ->
                        val newPermissionStates = newPermissions.mapValues {
                            if (it.value) {
                                PermissionStatus.Granted
                            } else {
                                PermissionStatus.Denied(
                                    shouldShowRationale = context.findActivity().shouldShowRationale(it.key)
                                )
                            }
                        }

                        Log.d(
                            TAG,
                            """
                               ------------
                               onPermissionResult
                               current: $currentPermissionsStates
                               new: $newPermissionStates
                               --------------
                            """.trimIndent()
                        )

                        // show settings when at least one permission is denied and the current and new permissions haven't changed.
                        if (newPermissionStates.any { !it.value.isGranted } && currentPermissionsStates == newPermissionStates) {
                            onCannotRequestPermission()
                        } else {
                            for (permission in permissions) {
                                val currentState = currentPermissionsStates[permission]
                                val newState = newPermissionStates[permission]
                                if (
                                    currentState is PermissionStatus.Denied && currentState.shouldShowRationale &&
                                    newState is PermissionStatus.Denied && !newState.shouldShowRationale
                                ) {
                                    onCannotRequestPermission()
                                    break
                                }
                            }
                        }

                        onPermissionsResult(newPermissions)
                    }
            )

    return remember(permissions) {
        ExtendedMultiplePermissionState(
                permissions = mutablePermissionState.permissions,
                mutablePermissionState = mutablePermissionState,
                onCannotRequestPermission = onCannotRequestPermission,
                atDoubleDenial = atDoubleDenialForPermission,
                onLaunchedPermissionRequest = {
                    currentPermissionsStates = permissionStates(context, permissions)

                    Log.d(
                        TAG,
                        """
                            onLaunchedPermissionRequest
                            current: $currentPermissionsStates
                        """.trimIndent()
                    )
                },
        )
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Stable
class ExtendedPermissionState(
        override val permission: String,
        private val mutablePermissionState: PermissionState,
        private val atDoubleDenial: Boolean,
        private val onLaunchedPermissionRequest: (shouldShowRationale: Boolean) -> Unit,
        private val onCannotRequestPermission: () -> Unit
) : PermissionState {
    override val status: PermissionStatus
        get() = mutablePermissionState.status

    override fun launchPermissionRequest() {
        onLaunchedPermissionRequest(mutablePermissionState.status.shouldShowRationale)
        if (atDoubleDenial) onCannotRequestPermission()
        else mutablePermissionState.launchPermissionRequest()
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Stable
class ExtendedMultiplePermissionState(
        override val permissions: List<PermissionState>,
        private val mutablePermissionState: MultiplePermissionsState,
        private val atDoubleDenial: Boolean,
        private val onLaunchedPermissionRequest: (shouldShowRationale: Boolean) -> Unit,
        private val onCannotRequestPermission: () -> Unit
) : MultiplePermissionsState {
    override val revokedPermissions: List<PermissionState> by derivedStateOf {
        permissions.filter { it.status != PermissionStatus.Granted }
    }

    override val allPermissionsGranted: Boolean by derivedStateOf {
        permissions.all { it.status.isGranted } || // Up to date when the lifecycle is resumed
        revokedPermissions.isEmpty() // Up to date when the user launches the action
    }

    override val shouldShowRationale: Boolean by derivedStateOf {
        permissions.any { it.status.shouldShowRationale } &&
                permissions.none { !it.status.isGranted && !it.status.shouldShowRationale }
    }

    override fun launchMultiplePermissionRequest() {
        onLaunchedPermissionRequest(mutablePermissionState.shouldShowRationale)
        if (atDoubleDenial) onCannotRequestPermission()
        else mutablePermissionState.launchMultiplePermissionRequest()
    }
}