Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Permissions] Clean up the APIs to guarantee a better UX #990

Merged
merged 2 commits into from Feb 7, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
117 changes: 20 additions & 97 deletions docs/permissions.md
Expand Up @@ -10,60 +10,6 @@ A library which provides [Android runtime permissions](https://developer.android

## Usage

### `PermissionRequired` and `PermissionsRequired` APIs

The `PermissionRequired` and `PermissionsRequired` composables offer an opinionated way of handling
the permissions status workflow as described in the
[documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions).

```kotlin
@Composable
private fun FeatureThatRequiresCameraPermission(
navigateToSettingsScreen: () -> Unit
) {
// Track if the user doesn't want to see the rationale any more.
var doNotShowRationale by rememberSaveable { mutableStateOf(false) }

val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
PermissionRequired(
permissionState = cameraPermissionState,
permissionNotGrantedContent = {
if (doNotShowRationale) {
Text("Feature not available")
} else {
Column {
Text("The camera is important for this app. Please grant the permission.")
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Ok!")
}
Spacer(Modifier.width(8.dp))
Button(onClick = { doNotShowRationale = true }) {
Text("Nope")
}
}
}
}
},
permissionNotAvailableContent = {
Column {
Text(
"Camera permission denied. See this FAQ with information about why we " +
"need this permission. Please, grant us access on the Settings screen."
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = navigateToSettingsScreen) {
Text("Open Settings")
}
}
}
) {
Text("Camera permission Granted")
}
}
```

### `rememberPermissionState` and `rememberMultiplePermissionsState` APIs

The `rememberPermissionState(permission: String)` API allows you to request a certain permission
Expand All @@ -79,62 +25,39 @@ Both APIs expose properties for you to follow the workflow as described in the
needs to be invoked from a non-composable scope. For example, from a side-effect or from a
non-composable callback such as a `Button`'s `onClick` lambda.

The following code exercises the [permission request workflow](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions)
and is nice with the user by letting them decide if they don't want to see the rationale again.
The following code exercises the [permission request workflow](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions).

```kotlin
@Composable
private fun FeatureThatRequiresCameraPermission(
navigateToSettingsScreen: () -> Unit
) {
// Track if the user doesn't want to see the rationale any more.
var doNotShowRationale by rememberSaveable { mutableStateOf(false) }
private fun FeatureThatRequiresCameraPermission() {

// Camera permission state
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.CAMERA
)

when {
when (state.status) {
// If the camera permission is granted, then show screen with the feature enabled
cameraPermissionState.hasPermission -> {
PermissionStatus.Granted -> {
Text("Camera permission Granted")
}
// If the user denied the permission but a rationale should be shown, or the user sees
// the permission for the first time, explain why the feature is needed by the app and allow
// the user to be presented with the permission again or to not see the rationale any more.
cameraPermissionState.shouldShowRationale ||
!cameraPermissionState.permissionRequested -> {
if (doNotShowRationale) {
Text("Feature not available")
} else {
Column {
Text("The camera is important for this app. Please grant the permission.")
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Request permission")
}
Spacer(Modifier.width(8.dp))
Button(onClick = { doNotShowRationale = true }) {
Text("Don't show rationale again")
}
}
}
}
}
// If the criteria above hasn't been met, the user denied the permission. Let's present
// the user with a FAQ in case they want to know more and send them to the Settings screen
// to enable it the future there if they want to.
else -> {
is PermissionStatus.Denied -> {
Column {
Text(
"Camera permission denied. See this FAQ with information about why we " +
"need this permission. Please, grant us access on the Settings screen."
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = navigateToSettingsScreen) {
Text("Open Settings")
val textToShow = if (state.status.shouldShowRationale) {
// If the user has denied the permission but the rationale can be shown,
// then gently explain why the app requires this permission
"The camera is important for this app. Please grant the permission."
} else {
// If it's the first time the user lands on this feature, or the user
// doesn't want to be asked again for this permission, explain that the
// permission is required
"Camera permission required for this feature to be available. " +
"Please grant the permission"
}

Text(textToShow)
Button(onClick = { state.launchPermissionRequest() }) {
Text("Request permission")
}
}
}
Expand Down
32 changes: 19 additions & 13 deletions permissions/api/current.api
Expand Up @@ -6,20 +6,18 @@ package com.google.accompanist.permissions {

@androidx.compose.runtime.Stable @com.google.accompanist.permissions.ExperimentalPermissionsApi public interface MultiplePermissionsState {
method public boolean getAllPermissionsGranted();
method public boolean getPermissionRequested();
method public java.util.List<com.google.accompanist.permissions.PermissionState> getPermissions();
method public java.util.List<com.google.accompanist.permissions.PermissionState> getRevokedPermissions();
method public boolean getShouldShowRationale();
method public void launchMultiplePermissionRequest();
property public abstract boolean allPermissionsGranted;
property public abstract boolean permissionRequested;
property public abstract java.util.List<com.google.accompanist.permissions.PermissionState> permissions;
property public abstract java.util.List<com.google.accompanist.permissions.PermissionState> revokedPermissions;
property public abstract boolean shouldShowRationale;
}

public final class MultiplePermissionsStateKt {
method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.MultiplePermissionsState rememberMultiplePermissionsState(java.util.List<java.lang.String> permissions);
method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.MultiplePermissionsState rememberMultiplePermissionsState(java.util.List<java.lang.String> permissions, optional kotlin.jvm.functions.Function1<? super java.util.Map<java.lang.String,java.lang.Boolean>,kotlin.Unit> onPermissionsResult);
}

public final class MutableMultiplePermissionsStateKt {
Expand All @@ -29,27 +27,35 @@ package com.google.accompanist.permissions {
}

@androidx.compose.runtime.Stable @com.google.accompanist.permissions.ExperimentalPermissionsApi public interface PermissionState {
method public boolean getHasPermission();
method public String getPermission();
method public boolean getPermissionRequested();
method public boolean getShouldShowRationale();
method public com.google.accompanist.permissions.PermissionStatus getStatus();
method public void launchPermissionRequest();
property public abstract boolean hasPermission;
property public abstract String permission;
property public abstract boolean permissionRequested;
property public abstract boolean shouldShowRationale;
property public abstract com.google.accompanist.permissions.PermissionStatus status;
}

public final class PermissionStateKt {
method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.PermissionState rememberPermissionState(String permission);
method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static com.google.accompanist.permissions.PermissionState rememberPermissionState(String permission, optional kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onPermissionResult);
}

@androidx.compose.runtime.Stable @com.google.accompanist.permissions.ExperimentalPermissionsApi public sealed interface PermissionStatus {
}

public static final class PermissionStatus.Denied implements com.google.accompanist.permissions.PermissionStatus {
ctor public PermissionStatus.Denied(boolean shouldShowRationale);
method public boolean component1();
method public com.google.accompanist.permissions.PermissionStatus.Denied copy(boolean shouldShowRationale);
method public boolean getShouldShowRationale();
property public final boolean shouldShowRationale;
}

public final class PermissionsRequiredKt {
method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static void PermissionRequired(com.google.accompanist.permissions.PermissionState permissionState, kotlin.jvm.functions.Function0<kotlin.Unit> permissionNotGrantedContent, kotlin.jvm.functions.Function0<kotlin.Unit> permissionNotAvailableContent, kotlin.jvm.functions.Function0<kotlin.Unit> content);
method @androidx.compose.runtime.Composable @com.google.accompanist.permissions.ExperimentalPermissionsApi public static void PermissionsRequired(com.google.accompanist.permissions.MultiplePermissionsState multiplePermissionsState, kotlin.jvm.functions.Function0<kotlin.Unit> permissionsNotGrantedContent, kotlin.jvm.functions.Function0<kotlin.Unit> permissionsNotAvailableContent, kotlin.jvm.functions.Function0<kotlin.Unit> content);
public static final class PermissionStatus.Granted implements com.google.accompanist.permissions.PermissionStatus {
field public static final com.google.accompanist.permissions.PermissionStatus.Granted INSTANCE;
}

public final class PermissionsUtilKt {
method public static boolean getShouldShowRationale(com.google.accompanist.permissions.PermissionStatus);
method public static boolean isGranted(com.google.accompanist.permissions.PermissionStatus);
}

}
Expand Down
Expand Up @@ -86,7 +86,7 @@ class MultipleAndSinglePermissionsTest {
composeTestRule.onNodeWithText("Navigate").performClick()
instrumentation.waitForIdleSync()
composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed()
composeTestRule.onNodeWithText("No permission").assertIsDisplayed()
composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed()
composeTestRule.onNodeWithText("Request").performClick()
grantPermissionInDialog()
composeTestRule.onNodeWithText("Granted").assertIsDisplayed()
Expand All @@ -110,7 +110,7 @@ class MultipleAndSinglePermissionsTest {
composeTestRule.onNodeWithText("Navigate").performClick()
instrumentation.waitForIdleSync()
composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed()
composeTestRule.onNodeWithText("No permission").assertIsDisplayed()
composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed()
uiDevice.pressBack()
instrumentation.waitForIdleSync()
composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed()
Expand Down Expand Up @@ -140,7 +140,7 @@ class MultipleAndSinglePermissionsTest {
composeTestRule.onNodeWithText("Navigate").performClick()
instrumentation.waitForIdleSync()
composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed()
composeTestRule.onNodeWithText("No permission").assertIsDisplayed()
composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed()
uiDevice.pressBack()
instrumentation.waitForIdleSync()
composeTestRule.onNodeWithText("MultipleAndSinglePermissionsTest").assertIsDisplayed()
Expand Down Expand Up @@ -196,7 +196,7 @@ class MultipleAndSinglePermissionsTest {
composeTestRule.onNodeWithText("Navigate").performClick()
instrumentation.waitForIdleSync()
composeTestRule.onNodeWithText("PermissionsTestActivity").assertIsDisplayed()
composeTestRule.onNodeWithText("No permission").assertIsDisplayed()
composeTestRule.onNodeWithText("ShowRationale").assertIsDisplayed()
composeTestRule.onNodeWithText("Request").performClick()
grantPermissionInDialog() // Grant the permission
composeTestRule.onNodeWithText("Granted").assertIsDisplayed()
Expand All @@ -221,37 +221,29 @@ class MultipleAndSinglePermissionsTest {
Column {
Text("MultipleAndSinglePermissionsTest")
Spacer(Modifier.height(16.dp))
when {
state.allPermissionsGranted -> {
Text("Granted")
}
state.shouldShowRationale || !state.permissionRequested -> {
Column {
if (state.permissionRequested) {
Text("ShowRationale")
} else {
Text("No permission")
}
Button(
onClick = {
if (
requestSinglePermission &&
state.permissionRequested &&
state.revokedPermissions.size == 1
) {
state.revokedPermissions[0].launchPermissionRequest()
} else {
state.launchMultiplePermissionRequest()
}
if (state.allPermissionsGranted) {
Text("Granted")
} else {
Column {
val textToShow = if (state.shouldShowRationale) {
"ShowRationale"
} else {
"No permission"
}

Text(textToShow)
Button(
onClick = {
if (requestSinglePermission && state.revokedPermissions.size == 1) {
state.revokedPermissions[0].launchPermissionRequest()
} else {
state.launchMultiplePermissionRequest()
}
) {
Text("Request")
}
) {
Text("Request")
}
}
else -> {
Text("Denied")
}
}
Spacer(Modifier.height(16.dp))
Button(
Expand Down
Expand Up @@ -42,8 +42,8 @@ class PermissionStateTest {
fun permissionState_hasPermission() {
composeTestRule.setContent {
val state = rememberPermissionState(android.Manifest.permission.CAMERA)
assertThat(state.hasPermission).isTrue()
assertThat(state.shouldShowRationale).isFalse()
assertThat(state.status.isGranted).isTrue()
assertThat(state.status.shouldShowRationale).isFalse()
}
}

Expand All @@ -57,8 +57,8 @@ class PermissionStateTest {
composeTestRule.setContent {
val state = rememberPermissionState(permission)

assertThat(state.hasPermission).isFalse()
assertThat(state.shouldShowRationale).isTrue()
assertThat(state.status.isGranted).isFalse()
assertThat(state.status.shouldShowRationale).isTrue()
}
}
}
Expand Up @@ -84,7 +84,7 @@ class RequestMultiplePermissionsTest {
}
doNotAskAgainPermissionInDialog() // Do not ask again second permission

composeTestRule.onNodeWithText("Denied").assertIsDisplayed()
composeTestRule.onNodeWithText("No permission").assertIsDisplayed()
}

@OptIn(ExperimentalCoroutinesApi::class)
Expand All @@ -101,7 +101,7 @@ class RequestMultiplePermissionsTest {
grantPermissionInDialog()
}
doNotAskAgainPermissionInDialog() // Do not ask again second permission
composeTestRule.onNodeWithText("Denied").assertIsDisplayed()
composeTestRule.onNodeWithText("No permission").assertIsDisplayed()

// This simulates the user going to the Settings screen and granting both permissions.
// This is cheating, I know, but the order in which the system request the permissions
Expand All @@ -125,23 +125,21 @@ class RequestMultiplePermissionsTest {
android.Manifest.permission.CAMERA
)
)
PermissionsRequired(
multiplePermissionsState = state,
permissionsNotAvailableContent = { Text("Denied") },
permissionsNotGrantedContent = {
Column {
if (state.permissionRequested) {
Text("ShowRationale")
} else {
Text("No permission")
}
Button(onClick = { state.launchMultiplePermissionRequest() }) {
Text("Request")
}
if (state.allPermissionsGranted) {
Text("Granted")
} else {
Column {
val textToShow = if (state.shouldShowRationale) {
"ShowRationale"
} else {
"No permission"
}

Text(textToShow)
Button(onClick = { state.launchMultiplePermissionRequest() }) {
Text("Request")
}
}
) {
Text("Granted")
}
}
}