Skip to content

Commit

Permalink
[Permissions] Clean up the APIs to guarantee a better UX (google#990)
Browse files Browse the repository at this point in the history
  • Loading branch information
Manuel Vivo authored Feb 7, 2022
1 parent fed405b commit a950658
Show file tree
Hide file tree
Showing 18 changed files with 324 additions and 500 deletions.
117 changes: 20 additions & 97 deletions docs/permissions.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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()
}
}
}
Original file line number Diff line number Diff line change
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")
}
}
}
Loading

0 comments on commit a950658

Please sign in to comment.