Android: How to ask and manage multiple permission states using Google Accompanist

Ajay Singh Thakurathi
7 min readAug 18, 2024

--

Prerequisites:
1. Platform knowledge for permission use
2. States/events and Side-effects in Jetpack Compose
3. Basic UI in Compose

I have heavily used the State concept, make sure you are good with it.

Things we’ll be covering:
1. How to handle multiple permission states
2. How do we update the permission Dialog as per the remaining permissions

The code is broken down into 4 parts,

1. MainActivity: Contains a state and call to PermissionDialog
2. PermissionDialogStateful (The boss Composable): everything happens here
3. PermissionDialogStateless: Initializing strings as per permission need and defining the Dialog
4. PermissionDialogUI: Just the UI for the Dialog

We’ll be covering fine location permission. It requires GPS and both fine & coarse location permission.

Let’s start with the usual dependency/manifest setup code.

Put this in your libs.version.toml file (this is the latest way of implementation, also I am using the dependencies through bundles, not important to the current task but you can explore it later)

accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version = "0.34.0" }

Add the above in gradle (app/module level)

implementation(libs.accompanist.permissions)

You can find the latest/stable version here:
https://mvnrepository.com/artifact/com.google.accompanist/accompanist-permissions

Add permissions in manifest,

<uses-feature
android:name="android.hardware.location.gps"
android:required="true" />

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

The <uses-feature> permission is optional here, just asserting that this app requires the enclosed list of features and will not be available to devices which don’t offer it.

Place these two methods in a Util class, for Permission check (nothing to understand here)

//checking for GPS
fun isGpsEnabled(context: Context): Boolean {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
var isGpsEnabled = false
try {
isGpsEnabled = lm.isProviderEnabled(LocationManager.GPS_PROVIDER)
} catch (ex: java.lang.Exception) {
Timber.tag(TAG).d(message = "isGpsEnabled: $ex")
}

return isGpsEnabled
}


//checking for location permission
fun hasLocationPermission(context: Context): Boolean {
val fineLoc: Boolean = ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarseLoc: Boolean = ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
return fineLoc && coarseLoc
}

MainAcitvity: Call to PermissionComposable

isPermissionGranted: this state is updated or initialised to true as all mandatory permissions are granted at runtime or launch respectively.

var isPermissionGranted by remember {
mutableStateOf(
value = hasLocationPermission(context = context) && isGpsEnabled(
context = context
)
)
}

Call to PermissionComposable

if (!isPermissionGranted) {
PermissionDialogStateful(
onPermissionGranted = {
if (isGpsEnabled(context) && hasLocationPermission(context)) {
TAG.d(message = "All perm Granted")
isPermissionGranted = true
}
})
} else {
Greeting(
name = "Android",
)
}

Real Code below

Dividing all of the Permission code into three parts or Composable,

  1. PermissionDialogStateful: Owner of all States
  2. PermissionDialogStateless: Initializing strings as per permissions
  3. PermissionDialogUI: Dialog UI

PermissionDialogStateful

 @Composable
fun PermissionDialogStateful(
onPermissionGranted: () -> Unit,
)

Something to keep in mind while asking for runtime permission in Android,

If the permission prompt is denied twice, the permission will enter the never_ask_again state which cannot be identified directly by any API, i.e. the permission request is never prompted again. The user needs to be navigated to settings where he needs to enable it.

Not to worry, we’ll be handling every case .

Declaring permission states

  1. State variables
    A. currRequest:
    To hold the current permission to update UI (this is set in upcoming code)
    B. isGPSEnabled: updates as per GPS/Location enabled/disabled status
    C. hasLocationPermission: updates as per location permission is granted
  2. LaunchedEffect with two keys:
    A. when block:
    Going down the “when” block, currRequest is set as the user keeps granting permission. Also, this code acts as a list of all required permissions.
    B. onPermissionGranted State: Checking all the permission states; as everything is satisfied, an event is called which sets the state in MainActivity as true causing reComposition, and dismissing the Dialog.
//region setting request type and observe permission state & dismiss permission dialog
var currRequest by remember { mutableStateOf<PermissionEnum?>(value = null) }

var isGpsEnabled by remember { mutableStateOf(isGpsEnabled(context = context)) }

var hasLocationPermission by remember { mutableStateOf(hasLocationPermission(context = context)) }


//This code block also acts as a list for all the necessary permissions
// Update `currRequest` when relevant states change
LaunchedEffect(isGpsEnabled, hasLocationPermission) {
currRequest = when {
!isGpsEnabled -> PermissionEnum.GPS
!hasLocationPermission -> PermissionEnum.LOCATION
else -> null
}
// Dismiss the dialog if all permissions are granted
if (isGpsEnabled && hasLocationPermission) {
onPermissionGranted()
}
}
//endregion

Identifying neverAskAgain situation

This is taken from this article, I am doing the same but in Compose
https://medium.com/@begalesagar/method-to-detect-if-user-has-selected-dont-ask-again-while-requesting-for-permission-921b95ded536

- There is one work around which uses shouldShowRequestPermissionRationale.
- Create a SharedPreference with default value false and store value returned by shouldShowRequestPermissionRationale in it.
- Before updating the value, check if the value set was true. If it was true then don’t update it.

- Whenever you want to check for permission, get the value from SharedPreference and current value returned by shouldShowRequestPermissionRationale.
- If shouldShowRequestPermissionRationale returns false but value from SharedPreference is true, you can deduce that Never ask again was selected by user.

showLocPermReq (In Datastore): preference state that can be observed, the same pref value described in the above text, var will be updated later (var name might not be the best)

locationPermState: State for location permission (courtesy of accompanist library)

val showLocPermReq by context.getShowLockPermReq().collectAsState(initial = false)


val locationPermState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
), onPermissionsResult = { statusMap: Map<String, Boolean> ->


if (!statusMap.values.contains(false)) {
hasLocationPermission = true
}
}
)

locationPermState.shouldShowRationale: this will return true only when the permission prompt is dismissed once, so….
- After zero denies (start) > false
- After first denial > true
- After 2 denies (neverAskAgain is active) > false

val neverAskAgain by remember {
derivedStateOf {
showLocPermReq && !locationPermState.shouldShowRationale
}
}

neverAskAgain State: as mentioned above you can check for neverAskAgain situation, I am checking for these two conditions >>>> showLocPermReq (must be true) && shouldShowRationale (must be false)

Launcher for Activity Result Contract

Launch settings, where
1. The location/GPS is turned off (which is rare these days but you have to check nonetheless)
2. The permission prompt is exhausted

The code operates on the currRequest state; in the response, permission states are updated accordingly

//to launch settings when either prompts are exhausted or prompt doesn't exist
val permissionSettingLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { _ ->
when (currRequest) {
PermissionEnum.GPS -> {
if (isGpsEnabled(context)) {
Timber.tag(TAG).d(message = "GPS from Settings Enabled")
isGpsEnabled = true
}
}


PermissionEnum.LOCATION -> {
if (hasLocationPermission(context)) {
Timber.tag(TAG).d(message = "Location from Settings Enabled")
hasLocationPermission = true
}
}


null -> {}
}
}

Updating the preference value (datastore in this project)

Updating the preference value that we are using in derivedStateOf (the reason is explained above)

LaunchedEffect(key1 = locationPermState.shouldShowRationale) {
if (!showLocPermReq && locationPermState.shouldShowRationale) {
context.setShowLockPermReq(isEnabled = true)
}
}

Showing the Dialog for Permission Rationale (when the currRequest != null)

currRequest?.let { requestType ->
PermissionDialogStateless(
permissionEnum = requestType,
isNeverAskAgain = neverAskAgain,
onAgreeClick = {
when (currRequest) {
PermissionEnum.GPS -> {
permissionSettingLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
}
PermissionEnum.LOCATION -> {
if (neverAskAgain) {
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts(
"package",
context.packageName,
null
)
}
permissionSettingLauncher.launch(input = intent)
} else {
locationPermState.launchMultiplePermissionRequest()
}
}


null -> {}
}
},
onDeclineClick = {
Toast.makeText(context, "Need Location Permission for API", Toast.LENGTH_SHORT)
.show()
}, modifier = Modifier
)
}

PermissionDialogStateless

@Composable
fun PermissionDialogStateless(
permissionEnum: PermissionEnum,
isNeverAskAgain: Boolean,
onAgreeClick: () -> Unit,
onDeclineClick: () -> Unit,
modifier: Modifier = Modifier
)

Dialog Code: Setting the title and description as per permission

Normally we use a state like “shouldShowDialog” for the Dialog’s visibility but here the parent itself is being removed from the composition which in turn will dismiss the Dialog.

val title = when (permissionEnum) {
PermissionEnum.GPS -> {
stringResource(id = R.string.gps_permission_title)
}

PermissionEnum.LOCATION -> {
stringResource(id = R.string.location_permission_title)
}
}

val desc = when (permissionEnum) {
PermissionEnum.GPS -> {
stringResource(id = R.string.gps_permission_desc)
}

PermissionEnum.LOCATION -> {
if (isNeverAskAgain) stringResource(id = R.string.location_permission_never_ask_again)
else stringResource(id = R.string.location_permission_desc)

}
}

Dialog(
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = false,
dismissOnClickOutside = false
), onDismissRequest = {
TAG.d(message = "onDismissRequest")
}) {
PermissionDialogUI(
title = title,
desc = desc,
onYesClick = onAgreeClick,
onNoClick = onDeclineClick,
modifier = modifier
)
}

PermissionDialogUI

Just the UI here (Compose UI can be confusing, If you have any doubts please ask in the comments),

@Composable
fun PermissionDialogUI(
title: String,
desc: String,
onYesClick: () -> Unit,
onNoClick: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
border = BorderStroke(
width = 1.dp, color = MaterialTheme.colorScheme.outline
),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.padding(horizontal = 30.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(align = Alignment.CenterVertically)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(top = 20.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = title,
letterSpacing = 0.sp,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 15.dp)
)
Image(
painter = painterResource(id = R.drawable.ic_location),
contentDescription = null,
colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.onSurface),
contentScale = ContentScale.FillBounds,
modifier = Modifier
.size(100.dp)
.padding(5.dp)
)
Text(
text = desc,
style = MaterialTheme.typography.bodyLarge,
letterSpacing = 0.sp,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 15.dp)
)
SeparatorSpacer(modifier = Modifier.padding(top = 10.dp))
Surface(
onClick = { onYesClick() },
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Text(
text = stringResource(id = R.string.allow),
letterSpacing = 0.sp,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge.copy(
background = MaterialTheme.colorScheme.surfaceContainerHigh
),
color = colorResource(id = R.color.allow),
fontWeight = FontWeight.Medium,
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(vertical = 10.dp, horizontal = 20.dp)
)
}

SeparatorSpacer()
Surface(
onClick = { onNoClick() },
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Text(
text = stringResource(id = R.string.decline),
letterSpacing = 0.sp,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge.copy(
background = MaterialTheme.colorScheme.surfaceContainerHigh
),
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(vertical = 10.dp, horizontal = 20.dp)
)
}
}
}
}

That’s it, I have covered most things here but highly recommend checking my GitHub repository for the same so that you can copy-paste :)

Edit 1: It just occurred to me that for never_ask_again state, we can just save all permission denies in pref. If pref_val ≥ 2 then never_ask_again state is active. It seems embarrassingly simple than all this code.

Last Note: This article/blog/post will always be free and can be useful or worthless depending upon your knowledge. If you find it helpful in any way please create an account and start clapping.

Thank you, Happy Coding!

--

--