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

Conversation

eygraber
Copy link

Still need to update the tests and docs, but looking to get feedback.

  - Also allow rememberPermissionState and rememberMultiplePermissionsState to be used in Compose Preview
@bentrengrove
Copy link
Collaborator

Haven't reviewed yet, but please summarise your API changes in the PR as well please. It helps to link back to from the release notes so people can see what changed and why.

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
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
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?

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.

@eygraber
Copy link
Author

eygraber commented Sep 23, 2024

With a12ee22 these states should be correct

NotRequested -> Grant in Settings -> Resume -> Granted
NotRequested -> Pause -> Resume -> !shouldShowRationale -> NotRequested

NotRequested -> Request -> Grant -> Resume -> Granted
NotRequested -> Request -> Deny -> Resume -> shouldShowRationale -> Denied
NotRequested -> Request -> NotShown -> Resume -> !shouldShowRationale -> PermanentlyDenied

Denied -> Grant in Settings -> Resume -> Granted
Denied -> Pause -> Resume -> shouldShowRationale -> Denied
Denied -> Request -> Cancel -> Resume -> shouldShowRationale -> Denied

PermanentlyDenied -> Grant in Settings -> Resume -> Granted
PermanentlyDenied -> Pause -> Resume -> !shouldShowRationale -> PermanentlyDenied
PermanentlyDenied -> Request -> Cancel -> Resume -> !shouldShowRationale -> PermanentlyDenied

The only issue as @alexvanyo pointed out, is if the user canceled the dialog:

NotRequested -> Request -> Cancel -> Resume -> !shouldShowRationale -> PermanentlyDenied

As a user, this doesn't happen to me often, but it does happen enough times (usually by mistake) that I can see it being a problem. Any ideas on how to handle that case?

@eygraber
Copy link
Author

eygraber commented Sep 23, 2024

My initial thought is that it's an intractable problem since there is no way to distinguish between these two states:

NotRequested -> Request -> NotShown -> Resume -> !shouldShowRationale -> PermanentlyDenied
NotRequested -> Request -> Cancel -> Resume -> !shouldShowRationale -> PermanentlyDenied

and a choice has to be made to optimize for one over the other:

  1. Remove the direct transition of NotRequested -> PermanentlyDenied and necessitate a Denied state in between
NotRequested -> Request -> NotShown -> Resume -> wasNotRequested && !shouldShowRationale -> Denied -> Request -> NotShown -> Resume -> wasRequested && !shouldShowRationale -> PermanentlyDenied

This forces the user to perform a click that they didn't need to (to go from Denied -> PermanentlyDenied) but allows the NotRequested -> Cancel scenario to terminate in Denied correctly. However, it is still better than before in that the user will eventually reach a PermanentlyDenied state.

  1. NotRequested -> Cancel results in PermanentlyDenied even though it wasn't actually PermanentlyDenied

I don't like #1, but I like #2 even less, so I'll add a commit implementing #1, and await feedback.

Update - a0b6b00

@alexvanyo
Copy link
Collaborator

Using the lifecycle effect, I wonder if it is possible to infer whether the permission dialog was actually shown (in the not requested state) versus no permission dialog shown at all (in the permanently denied state)?

This is even more of an edge case, but it would also be possible for the user to cancel the dialog repeatedly forever (request permission -> cancel -> request permission -> request permission -> request permission -> ...)

@eygraber
Copy link
Author

Using the lifecycle effect, I wonder if it is possible to infer whether the permission dialog was actually shown

I think the OS (at least in later versions) protects against this and always pauses the requesting Activity for some amount of time so it isn't possible to infer.

This is even more of an edge case, but it would also be possible for the user to cancel the dialog repeatedly forever

That might be the point where a line has to be drawn between supporting that case and supporting a permanently denied state 🤔

I can see situations where the repeated cancelling could come up in the real world, but they'd still have the escape hatch of granting the permission in settings. Maybe it could be configurable, so the dev could choose between handling cancelled requests or a permanently denied status?

@bentrengrove
Copy link
Collaborator

I am actually remembering now, this is why we didn't support this in the first place. Unfortunately, unless we can work this out I don't think we could land this in Accompanist, which I'm sorry for because I told you it would be ok. I think it is probably fine in your library but we can't have inconsistent behaviour in an "official" library.

Let me see if I can find an internal expert

@eygraber
Copy link
Author

I went over it a few times today, and I think with the public API there's no way around that.

If there's a hidden API or some way to tap in to the OS to get that info, or if there's an expert that knows of a non obvious way to detect the difference between the dialog getting canceled and being permanently denied, that would be great!

@bentrengrove
Copy link
Collaborator

So the answer I got is that the activity result contract should be returning an emptyMap and you could check for that to determine if a dialog was shown and the request was cancelled. Do you want to try that and see if it helps?

@eygraber
Copy link
Author

eygraber commented Oct 1, 2024

Interesting, I'll take a look at that.

@eygraber
Copy link
Author

eygraber commented Oct 1, 2024

@bentrengrove it looks like the RequestMultiplePermissions activity result contract handles those cases, but my testing has found that the result from permissions will always come back as denied whether it is permanently denied or cancelled, so the map is never empty.

@bentrengrove
Copy link
Collaborator

What happens if you just always use RequestMultiplePermissions even if it's a single permission?

@eygraber
Copy link
Author

eygraber commented Oct 2, 2024

Same result as single:

resultCode == Activity.RESULT_OK
intent.getStringArrayExtras(EXTRA_PERMISSIONS) -> ["android.permission..."]
intent.getIntArrayExtra(EXTRA_PERMISSION_GRANT_RESULTS) -> [-1]

@bentrengrove
Copy link
Collaborator

How are you testing? Just in the accompanist sample app? I will give it a try

@eygraber
Copy link
Author

eygraber commented Oct 2, 2024

Yes, in the sample app.

@bentrengrove
Copy link
Collaborator

Can you push up your changes please? Doesn't look like sample builds right now and I want to make sure we are testing the same thing

@eygraber
Copy link
Author

eygraber commented Oct 2, 2024

I'm AFK for the next few days. I can do it when I'm back, but the changes were pretty minimal (mostly just revoked -> denied IIRC)

@eygraber
Copy link
Author

eygraber commented Oct 6, 2024

@bentrengrove updated the branch so that the sample builds and runs

@bentrengrove
Copy link
Collaborator

Thanks for that, I gave it a go and confirmed the same thing. I can't find any way to detect cancellation for permissions. Even the multiple permission request always returns -1 for me, never emptyMap(). I will go back and see what I can find out

@eygraber
Copy link
Author

@bentrengrove any update here?

@bentrengrove
Copy link
Collaborator

Not yet sorry, still trying to find a solution but it's not looking good unfortunately

@bashtian
Copy link

What works for me is checking the time it takes for the result to come back, whether a permission dialog is shown or not.

    val context = LocalContext.current.findActivity()
    var launchTime by remember { mutableLongStateOf(0L) }
    var permissionGranted by remember { mutableStateOf(false) }

    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGranted: Boolean ->
            permissionGranted = isGranted

            val isPermanentlyDenied = System.currentTimeMillis() - launchTime < 100 // normally it takes 30ms on my device for the result to come back
            if (isPermanentlyDenied) {
                context.startActivity(
                    Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
                        .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
                )
            }
        }
    )
    if (!permissionGranted) {
        Button(
            onClick = {
                launchTime = System.currentTimeMillis()
                permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        ) {
            Text("Grant permission")
        }
    }

@eygraber
Copy link
Author

Not yet sorry, still trying to find a solution but it's not looking good unfortunately

Unfortunately, unless we can work this out I don't think we could land this in Accompanist...we can't have inconsistent behaviour in an "official" library

I think part of the frustration here is that the whole permission system seems inconsistent to developers. My understanding of that is that it's for user safety as well as discouraging certain UX paradigms (e.g. redirecting to app settings), and I'm sure there are other considerations as well.

However it leaves us in a situation where the underlying system is perceived as broken (e.g. not being able to differentiate between a permission getting permanently denied and cancelling the permission request).

I know that following the permission workflow would make it easier to operate with these constraints, but the honest truth is that I've never worked with a product/design team that found that workflow acceptable. That led me to look for and implement workarounds, which all ultimately ended up not working in all cases. My experience is not unique, and there are countless QAs about this topic, and just as many broken workarounds.

@bentrengrove @alexvanyo I'm not directing any of this at you, and some of these issues go back almost a decade. I'm hoping that you can surface this issue internally, and that hopefully there can be some way to address it.

@bentrengrove
Copy link
Collaborator

I agree with you and would really love to have this feature in Accompanist. I am trying to raise it internally but I created a public issue as well that you could cc yourself to and hopefully we can post any results in there as well

https://issuetracker.google.com/375026897

@sindrenm
Copy link

sindrenm commented Oct 23, 2024

However it leaves us in a situation where the underlying system is perceived as broken (e.g. not being able to differentiate between a permission getting permanently denied and cancelling the permission request).

Pardon me for shooting in here, but I have always believed this to be an intentional decision made by the platform team to “protect” users. That not knowing whether the user directly denied a permission or whether the system denied on behalf of the user is actually a feature.

Of course, as a developer, I find this incredibly frustrating, because I don't know when to send users to Settings to enable stuff there if the permission has been permanently denied, so I would personally love to have the ability to differentiate between these states built-in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants