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] Permissions enhancements #1793

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ import java.util.EnumSet
public class PermissionsLaunchDetector : Detector(), SourceCodeScanner {

override fun getApplicableMethodNames(): List<String> = listOf(
LaunchPermissionRequest.shortName, LaunchMultiplePermissionsRequest.shortName
LaunchPermissionRequest.shortName, LaunchMultiplePermissionsRequest.shortName,
LaunchPermissionRequestOrAppSettings.shortName,
LaunchMultiplePermissionsRequestOrAppSettings.shortName,
OpenAppSettings.shortName
)

override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
Expand Down Expand Up @@ -87,5 +90,11 @@ private fun PsiMethod.isInPackageName(packageName: PackageName): Boolean =
private val PermissionsPackageName = Package("com.google.accompanist.permissions")
private val LaunchPermissionRequest =
Name(PermissionsPackageName, "launchPermissionRequest")
private val LaunchPermissionRequestOrAppSettings =
Name(PermissionsPackageName, "launchPermissionRequestOrAppSettings")
private val LaunchMultiplePermissionsRequest =
Name(PermissionsPackageName, "launchMultiplePermissionRequest")
private val LaunchMultiplePermissionsRequestOrAppSettings =
Name(PermissionsPackageName, "launchMultiplePermissionRequestOrAppSettings")
private val OpenAppSettings =
Name(PermissionsPackageName, "openAppSettings")
46 changes: 36 additions & 10 deletions permissions/api/current.api
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,31 @@ package com.google.accompanist.permissions {
}

@androidx.compose.runtime.Stable @com.google.accompanist.permissions.ExperimentalPermissionsApi public interface MultiplePermissionsState {
method public boolean getAllNotGrantedPermissionsArePermanentlyDenied();
method public boolean getAllPermissionsGranted();
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 boolean isNotRequested();
method public void launchMultiplePermissionRequest();
method public void openAppSettings();
property public abstract boolean allNotGrantedPermissionsArePermanentlyDenied;
property public abstract boolean allPermissionsGranted;
property public abstract boolean isNotRequested;
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 public static inline java.util.List<com.google.accompanist.permissions.PermissionState> getDeniedPermissions(com.google.accompanist.permissions.MultiplePermissionsState);
method public static inline java.util.List<com.google.accompanist.permissions.PermissionState> getGrantedPermissions(com.google.accompanist.permissions.MultiplePermissionsState);
method public static inline java.util.List<com.google.accompanist.permissions.PermissionState> getNotGrantedPermissions(com.google.accompanist.permissions.MultiplePermissionsState);
method public static inline java.util.List<com.google.accompanist.permissions.PermissionState> getPermanentlyDeniedPermissions(com.google.accompanist.permissions.MultiplePermissionsState);
method @com.google.accompanist.permissions.ExperimentalPermissionsApi public static boolean isDenied(com.google.accompanist.permissions.MultiplePermissionsState, String permission);
method @com.google.accompanist.permissions.ExperimentalPermissionsApi public static boolean isGranted(com.google.accompanist.permissions.MultiplePermissionsState, String permission);
method @com.google.accompanist.permissions.ExperimentalPermissionsApi public static boolean isNotGranted(com.google.accompanist.permissions.MultiplePermissionsState, String permission);
method @com.google.accompanist.permissions.ExperimentalPermissionsApi public static boolean isPermanentlyDenied(com.google.accompanist.permissions.MultiplePermissionsState, String permission);
method @com.google.accompanist.permissions.ExperimentalPermissionsApi public static void launchMultiplePermissionRequestOrAppSettings(com.google.accompanist.permissions.MultiplePermissionsState);
method @androidx.compose.runtime.Composable public static com.google.accompanist.permissions.MultiplePermissionsState rememberMultiplePermissionsState(String permission, String![] otherPermissions, optional kotlin.jvm.functions.Function1<? super java.util.Map<java.lang.String,java.lang.Boolean>,kotlin.Unit> onPermissionsResult);
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);
}

Expand All @@ -30,32 +43,45 @@ package com.google.accompanist.permissions {
method public String getPermission();
method public com.google.accompanist.permissions.PermissionStatus getStatus();
method public void launchPermissionRequest();
method public void openAppSettings();
property public abstract String permission;
property public abstract com.google.accompanist.permissions.PermissionStatus status;
}

public final class PermissionStateKt {
method @com.google.accompanist.permissions.ExperimentalPermissionsApi public static void launchPermissionRequestOrAppSettings(com.google.accompanist.permissions.PermissionState);
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 static final class PermissionStatus.Granted implements com.google.accompanist.permissions.PermissionStatus {
field public static final com.google.accompanist.permissions.PermissionStatus.Granted INSTANCE;
}

public static sealed interface PermissionStatus.NotGranted extends com.google.accompanist.permissions.PermissionStatus {
}

public static final class PermissionStatus.NotGranted.Denied implements com.google.accompanist.permissions.PermissionStatus.NotGranted {
field public static final com.google.accompanist.permissions.PermissionStatus.NotGranted.Denied INSTANCE;
}

public static final class PermissionStatus.NotGranted.NotRequested implements com.google.accompanist.permissions.PermissionStatus.NotGranted {
field public static final com.google.accompanist.permissions.PermissionStatus.NotGranted.NotRequested INSTANCE;
}

public static final class PermissionStatus.NotGranted.PermanentlyDenied implements com.google.accompanist.permissions.PermissionStatus.NotGranted {
field public static final com.google.accompanist.permissions.PermissionStatus.NotGranted.PermanentlyDenied INSTANCE;
}

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

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,29 @@
package com.google.accompanist.permissions

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.util.fastMap

/**
* Creates a [MultiplePermissionsState] that is remembered across compositions.
*
* @param permission a permission to control and observe.
* @param otherPermissions additional permissions to control and observe.
* @param onPermissionsResult will be called with whether or not the user granted the permissions
* after [MultiplePermissionsState.launchMultiplePermissionRequest] is called.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
public fun rememberMultiplePermissionsState(
permission: String,
vararg otherPermissions: String,
onPermissionsResult: (Map<String, Boolean>) -> Unit = {}
): MultiplePermissionsState = rememberMultiplePermissionsState(
permissions = listOf(permission) + otherPermissions.toList(),
onPermissionsResult = onPermissionsResult,
)

/**
* Creates a [MultiplePermissionsState] that is remembered across compositions.
Expand All @@ -35,7 +57,10 @@ public fun rememberMultiplePermissionsState(
permissions: List<String>,
onPermissionsResult: (Map<String, Boolean>) -> Unit = {}
): MultiplePermissionsState {
return rememberMutableMultiplePermissionsState(permissions, onPermissionsResult)
return when {
LocalInspectionMode.current -> PreviewMultiplePermissionsState(permissions)
else -> rememberMutableMultiplePermissionsState(permissions, onPermissionsResult)
}
}

/**
Expand All @@ -56,15 +81,20 @@ public interface MultiplePermissionsState {
public val permissions: List<PermissionState>

/**
* List of permissions revoked by the user.
* When `true`, the user hasn't requested [permissions] yet.
*/
public val revokedPermissions: List<PermissionState>
public val isNotRequested: Boolean

/**
* When `true`, the user has granted all [permissions].
*/
public val allPermissionsGranted: Boolean

/**
* When `true`, the user has permanently denied all [permissions] that haven't been granted.
*/
public val allNotGrantedPermissionsArePermanentlyDenied: Boolean

/**
* When `true`, the user should be presented with a rationale.
*/
Expand All @@ -82,4 +112,118 @@ public interface MultiplePermissionsState {
* This behavior varies depending on the Android level API.
*/
public fun launchMultiplePermissionRequest(): Unit

/**
* Open the app settings page.
*
* If the first request permission in [permissions] is [android.Manifest.permission.POST_NOTIFICATIONS] then
* the notification settings will be opened. Otherwise the app's settings will be opened.
*
* This should always be triggered from non-composable scope, for example, from a side-effect
* or a non-composable callback. Otherwise, this will result in an IllegalStateException.
*/
public fun openAppSettings(): Unit
}

/**
* Calls [MultiplePermissionsState.openAppSettings] when
* [MultiplePermissionsState.allNotGrantedPermissionsArePermanentlyDenied] is `true`; otherwise calls
* [MultiplePermissionsState.launchMultiplePermissionRequest].
*
* This should always be triggered from non-composable scope, for example, from a side-effect
* or a non-composable callback. Otherwise, this will result in an IllegalStateException.
*/
@ExperimentalPermissionsApi
public fun MultiplePermissionsState.launchMultiplePermissionRequestOrAppSettings() {
when {
allNotGrantedPermissionsArePermanentlyDenied -> openAppSettings()
else -> launchMultiplePermissionRequest()
}
}

/**
* List of permissions granted by the user.
*/
@ExperimentalPermissionsApi
public inline val MultiplePermissionsState.grantedPermissions: List<PermissionState>
get() = permissions.filter { it.status.isGranted }

/**
* List of permissions not granted by the user.
*/
@ExperimentalPermissionsApi
public inline val MultiplePermissionsState.notGrantedPermissions: List<PermissionState>
get() = permissions.filter { it.status.isNotGranted }

/**
* List of permissions denied by the user.
*/
@ExperimentalPermissionsApi
public inline val MultiplePermissionsState.deniedPermissions: List<PermissionState>
get() = permissions.filter { it.status.isDenied }

/**
* List of permissions permanently denied by the user.
*/
@ExperimentalPermissionsApi
public inline val MultiplePermissionsState.permanentlyDeniedPermissions: List<PermissionState>
get() = permissions.filter { it.status.isPermanentlyDenied }

/**
* Returns `true` if [permission] was granted, otherwise `false`.
*
* If [permission] wasn't requested a [IllegalArgumentException] will be thrown.
*/
@ExperimentalPermissionsApi
public fun MultiplePermissionsState.isGranted(permission: String): Boolean =
requireNotNull(permissions.find { it.permission == permission }) {
"$permission is not present in the list of requested permissions"
}.status.isGranted

/**
* Returns `true` if [permission] was not granted, otherwise `false`.
*
* If [permission] wasn't requested a [IllegalArgumentException] will be thrown.
*/
@ExperimentalPermissionsApi
public fun MultiplePermissionsState.isNotGranted(permission: String): Boolean =
requireNotNull(permissions.find { it.permission == permission }) {
"$permission is not present in the list of requested permissions"
}.status.isNotGranted

/**
* Returns `true` if [permission] was denied, otherwise `false`.
*
* If [permission] wasn't requested a [IllegalArgumentException] will be thrown.
*/
@ExperimentalPermissionsApi
public fun MultiplePermissionsState.isDenied(permission: String): Boolean =
requireNotNull(permissions.find { it.permission == permission }) {
"$permission is not present in the list of requested permissions"
}.status.isDenied

/**
* Returns `true` if [permission] was permanently denied, otherwise `false`.
*
* If [permission] wasn't requested a [IllegalArgumentException] will be thrown.
*/
@ExperimentalPermissionsApi
public fun MultiplePermissionsState.isPermanentlyDenied(permission: String): Boolean =
requireNotNull(permissions.find { it.permission == permission }) {
"$permission is not present in the list of requested permissions"
}.status.isPermanentlyDenied

@OptIn(ExperimentalPermissionsApi::class)
@Immutable
private class PreviewMultiplePermissionsState(
permissions: List<String>
) : MultiplePermissionsState {
override val permissions: List<PermissionState> = permissions.fastMap(::PreviewPermissionState)
override val isNotRequested: Boolean = true
override val allPermissionsGranted: Boolean = false
override val allNotGrantedPermissionsArePermanentlyDenied: Boolean = false
override val shouldShowRationale: Boolean = false

override fun launchMultiplePermissionRequest() {}
override fun openAppSettings() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.util.fastAll

/**
* Creates a [MultiplePermissionsState] that is remembered across compositions.
Expand Down Expand Up @@ -117,15 +118,16 @@ internal class MutableMultiplePermissionsState(

override val permissions: List<PermissionState> = mutablePermissions

override val revokedPermissions: List<PermissionState> by derivedStateOf {
permissions.filter { it.status != PermissionStatus.Granted }
}
override var isNotRequested: Boolean = true

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
notGrantedPermissions.isEmpty() // Up to date when the user launches the action
}

override val allNotGrantedPermissionsArePermanentlyDenied: Boolean
get() = permissions.fastAll { it.status.isGranted || it.status.isPermanentlyDenied }

override val shouldShowRationale: Boolean by derivedStateOf {
permissions.any { it.status.shouldShowRationale }
}
Expand All @@ -136,9 +138,15 @@ internal class MutableMultiplePermissionsState(
) ?: throw IllegalStateException("ActivityResultLauncher cannot be null")
}

override fun openAppSettings() {
permanentlyDeniedPermissions.firstOrNull()?.openAppSettings()
}

internal var launcher: ActivityResultLauncher<Array<String>>? = null

internal fun updatePermissionsStatus(permissionsStatus: Map<String, Boolean>) {
isNotRequested = false

// Update all permissions with the result
for (permission in permissionsStatus.keys) {
mutablePermissions.firstOrNull { it.permission == permission }?.apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,26 +86,41 @@ internal class MutablePermissionState(
private val activity: Activity
) : PermissionState {

override var status: PermissionStatus by mutableStateOf(getPermissionStatus())
override var status: PermissionStatus by mutableStateOf(getPermissionStatus(false))

override fun launchPermissionRequest() {
launcher?.launch(
permission
) ?: throw IllegalStateException("ActivityResultLauncher cannot be null")
}

override fun openAppSettings() {
activity.openAppSettings(permission)
}

internal var launcher: ActivityResultLauncher<String>? = null

internal fun refreshPermissionStatus() {
status = getPermissionStatus()
status = getPermissionStatus(isPermissionPostRequest = true)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshPermissionStatus() is also called from PermissionLifecycleCheckerEffect, so right now the initial lifecycle event refreshing the status when the permission isn't granted will result in a status of PermanentlyDenied.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can track isPermissionPostRequest as its own property and set it separately from refreshPermissionStatus, since once that is set to true there shouldn't be any scenario that would make it go back to false.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any ordering guarantees between rememberLauncherForActivityResult and PermissionLifecycleCheckerEffect?

}

private fun getPermissionStatus(): PermissionStatus {
private fun getPermissionStatus(isPermissionPostRequest: Boolean): PermissionStatus {
val hasPermission = context.checkPermission(permission)
return if (hasPermission) {
PermissionStatus.Granted
} else {
PermissionStatus.Denied(activity.shouldShowRationale(permission))
val shouldShowRationale = activity.shouldShowRationale(permission)
when {
isPermissionPostRequest -> when {
shouldShowRationale -> PermissionStatus.NotGranted.Denied
else -> PermissionStatus.NotGranted.PermanentlyDenied
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a case here where it's possible to have requested the permission and shouldShowRationale is false, but without the permission being permanently denied.

If a permission is requested for the first time, and the user uses the back button to exit the permission dialog without selecting any of the options, the permission won't be permanently denied. If the permission is requested again, then the dialog will be shown again normally. Also, since the user did not explicitly deny the permission, shouldShowRationale will still be false.

}

else -> when {
shouldShowRationale -> PermissionStatus.NotGranted.Denied
else -> PermissionStatus.NotGranted.NotRequested
}
}
}
}
}
Loading
Loading