diff --git a/ANDROID_PERMANENTLY_DENIED_FIX_GUIDE.md b/ANDROID_PERMANENTLY_DENIED_FIX_GUIDE.md new file mode 100644 index 000000000..9d4f48f71 --- /dev/null +++ b/ANDROID_PERMANENTLY_DENIED_FIX_GUIDE.md @@ -0,0 +1,216 @@ +# Adopting the Android "permanently denied" fix in an app + +`permission_handler_android` 14.1.0 changes what a status check reports for a +denied permission on Android. This guide explains the change, tells you how to +write permission code that stays correct with it, and how to audit an existing +app for the pattern that the old behavior encouraged. + +## 1. What changed, in one paragraph + +On Android, `Permission.x.status` can no longer return `permanentlyDenied`. +It returns `denied` for every denied runtime permission. Only the result of +`Permission.x.request()` can be `permanentlyDenied`. Android gives apps no way +to tell a permanently denied permission apart from one that was never requested +or one that the user reset to **Ask every time** in the app settings, and the +old heuristic guessed wrong in the last case: an app could get stuck sending +the user to the app settings even though the OS was ready to show the request +dialog again ([#1206](https://github.com/Baseflow/flutter-permission-handler/issues/1206)). +Requesting a permanently denied permission is cheap: the OS resolves it +immediately, without showing a dialog. + +iOS is unchanged: `status` still returns `permanentlyDenied` there. The +request-driven pattern below works on both platforms. + +## 2. Behavior on Android after the fix + +| Situation on the device | `status` returns | `request()` returns | +|---|---|---| +| Never asked | `denied` | dialog shown. `granted`, or `denied` after "Don't allow" or a dismissal | +| Denied once | `denied` | dialog shown. `granted`, or `permanentlyDenied` after a second "Don't allow" | +| Denied twice (permanently denied) | `denied` | `permanentlyDenied`, immediately, no dialog | +| Reset to "Ask every time" in settings | `denied` | dialog shown. `granted`, or `denied` after "Don't allow" | +| Granted | `granted` | `granted`, immediately, no dialog | + +The row that used to be wrong is the fourth one: `status` reported +`permanentlyDenied` and `request()` showed the dialog anyway. + +## 3. Upgrade + +Take `permission_handler` 13.0.2 or later, which depends on +`permission_handler_android` 14.1.0: + +```yaml +dependencies: + permission_handler: ^13.0.2 +``` + +`permission_handler_android` 14.x compiles against API 37, a requirement +introduced in 14.0.0. If you are coming from 13.x, set `compileSdk` in +`android/app/build.gradle(.kts)`: + +```kotlin +android { + compileSdk = 37 +``` + +Then run `flutter pub get` and confirm the resolved version: + +```bash +flutter pub get +grep -A 3 "^ permission_handler_android:" pubspec.lock +``` + +## 4. Rules for the app's permission code + +1. **Never derive "permanently denied" from `status`.** Use `status` only to + skip the request when the permission is already `granted` or `limited`, and + to decide whether to show your own explainer before asking. +2. **Always call `request()` when you need a permission that is not granted.** + This is safe when it is permanently denied: no dialog appears and the result + comes back immediately. +3. **Branch on the `request()` result.** + - `granted` or `limited`: proceed. + - `denied`: the user declined just now. Do not re-prompt immediately, offer + the feature again on the next user action. + - `permanentlyDenied`: offer an "Open settings" action via + `openAppSettings()`. +4. **Never persist a `permanentlyDenied` verdict.** Not to disk, and not as a + long-lived flag that blocks future requests. After the app resumes from + Settings, read `status` again; if it is not `granted`, the next user action + calls `request()` again. +5. **Do not use `shouldShowRequestRationale` to detect permanent denial.** It + is `false` for "never asked", "Ask every time" and "permanently denied" + alike. +6. **Do not gate `request()` on your own `canRequest` style flag that excludes + `permanentlyDenied`.** That is exactly the pattern that keeps users stuck in + Settings. + +Reference pattern: + +```dart +Future ensureLocation() async { + var status = await Permission.locationWhenInUse.status; + if (status.isGranted || status.isLimited) return true; + + // Optionally show your own explainer here when status.isDenied. + + status = await Permission.locationWhenInUse.request(); + if (status.isGranted || status.isLimited) return true; + + if (status.isPermanentlyDenied) { + // Offer "Open settings". Do not remember this decision. + await showOpenSettingsPrompt(); + return false; + } + // Denied just now: stay quiet, let the user try again later. + return false; +} +``` + +## 5. Auditing an existing app + +Search the app for `isPermanentlyDenied` and `permanentlyDenied`. Keep every +usage that is fed by the result of a `request()` call, and rewrite the ones fed +by a `status` read. These are the three shapes that show up most: + +**A "can request" helper that excludes permanently denied.** This is the one +that keeps a feature from ever asking again: + +```dart +// Before: a permanently denied permission — and, before the fix, one reset to +// "Ask every time" — is never requested again. +bool get canRequest => + this == PermissionStatus.denied || this == PermissionStatus.restricted; + +// After: requesting is always safe when the permission is not granted. +bool get canRequest => + this != PermissionStatus.granted && this != PermissionStatus.limited; +``` + +**An early return on a status read.** Code that refreshes statuses and bails +out to a "go to settings" screen when one of them is `permanentlyDenied` is +dead code on Android now. Drop the early return and let the following +`request()` decide; it resolves immediately for a permanently denied permission +and yields the same outcome. + +**A "already asked" guard that survives a trip to Settings.** A flag like +`_permissionsRequested`, set once per screen visit, stops the second request. +When the user returns from Settings after choosing **Ask every time**, the +screen re-initializes, sees `denied`, skips the request and shows the settings +prompt again instead of the system dialog. Reset the flag before calling +`openAppSettings()`, or when the app resumes: + +```dart +/// Allow the next request to show the system dialog again, for example after +/// the user returns from the app settings. +void allowPermissionRePrompt() => _permissionsRequested = false; +``` + +Cached status values are fine as long as they are refreshed. On Android a +refresh never yields `permanentlyDenied`, so a stored `permanentlyDenied` is +only true right after a request stored its result and drops back to `denied` on +the next refresh. That is intended: the user may have changed the permission in +Settings in the meantime. + +## 6. Verify on a device or emulator + +Use a debug build of the app. `` is the application id. + +1. Fresh install. Trigger the feature, tap **Don't allow**. The app must + behave as "denied" (no settings prompt). +2. Trigger it again, tap **Don't allow**. The request result is + `permanentlyDenied`; the app shows its "Open settings" prompt. + Check the flags: + + ```bash + adb shell dumpsys package | grep -A1 "ACCESS_FINE_LOCATION: granted" + # expect: granted=false, flags=[ USER_SET|USER_FIXED|... ] + ``` + +3. Open Settings > Apps > the app > Permissions > Location, choose + **Ask every time**, return to the app. + + ```bash + adb shell dumpsys package | grep -A1 "ACCESS_FINE_LOCATION: granted" + # expect: granted=false, flags=[ ...|ONE_TIME ] (no USER_SET, no USER_FIXED) + ``` + + Trigger the feature: the system dialog must appear. Before the fix the app + went straight to its "Open settings" prompt. +4. Tap **While using the app**: the feature works and `status` is `granted`. + +To reset the permission between runs without reinstalling: + +```bash +adb shell pm revoke android.permission.ACCESS_FINE_LOCATION +adb shell pm revoke android.permission.ACCESS_COARSE_LOCATION +adb shell pm clear-permission-flags android.permission.ACCESS_FINE_LOCATION user-set user-fixed +adb shell pm clear-permission-flags android.permission.ACCESS_COARSE_LOCATION user-set user-fixed +``` + +The `ONE_TIME` flag cannot be cleared from the shell; choose **Don't allow** in +Settings first, then run the commands above. + +## 7. If an app cannot upgrade yet + +The unfixed plugin is wrong only about `status`. Apps on an older version can +avoid the stuck state by following the rules in section 4 today: ignore +`status.isPermanentlyDenied` on Android, always call `request()` and act on its +result. With the old plugin a request after **Ask every time** shows the +dialog and reports `granted` or `denied` correctly; only the pre-check was +lying. + +## 8. Background + +- Root cause: choosing **Ask every time** revokes the permission as a one-time + permission, which clears `FLAG_PERMISSION_USER_SET`. + `shouldShowRequestPermissionRationale()` returns exactly that flag, so it is + `false`, and the old plugin combined that with a never-cleared + "was denied before" flag in `SharedPreferences`. +- A second denial is now detected from the change of + `shouldShowRequestPermissionRationale()` across the request (`true` -> + `false`), so it is reported as `permanentlyDenied` even when the first denial + happened in the app settings. +- Upstream issue: [#1206](https://github.com/Baseflow/flutter-permission-handler/issues/1206). +- See the `permission_handler_android` 14.1.0 entry in its + [CHANGELOG](https://github.com/Baseflow/flutter-permission-handler/blob/main/permission_handler_android/CHANGELOG.md). diff --git a/permission_handler/CHANGELOG.md b/permission_handler/CHANGELOG.md index 534d08f57..46b35adb6 100644 --- a/permission_handler/CHANGELOG.md +++ b/permission_handler/CHANGELOG.md @@ -1,3 +1,9 @@ +## 13.0.2 + +- Updates the README to detect a permanent denial on Android from the result of `request()`, as `status` cannot detect it. +- Updates `permission_handler_android` to version 14.1.0, which fixes `status` reporting `permanentlyDenied` after the user selected "Ask every time" in the Android app settings (see issue [#1206](https://github.com/Baseflow/flutter-permission-handler/issues/1206)). +- Adds the [Android "permanently denied" guide](https://github.com/Baseflow/flutter-permission-handler/blob/main/ANDROID_PERMANENTLY_DENIED_FIX_GUIDE.md), explaining the changed Android behavior, the request-driven pattern to use and how to audit an existing app for code that relies on the old behavior. + ## 13.0.1 - Updates documentation on how to configure the permission handler on macOS / iOS. diff --git a/permission_handler/README.md b/permission_handler/README.md index 2159ae49b..88dca2854 100644 --- a/permission_handler/README.md +++ b/permission_handler/README.md @@ -299,7 +299,9 @@ You can get a `Permission`'s `status`, which is either `granted`, `denied`, `res ```dart var status = await Permission.camera.status; if (status.isDenied) { - // We haven't asked for permission yet or the permission has been denied before, but not permanently. + // We haven't asked for permission yet or the permission has been denied before. + // On Android this also covers a permanently denied permission: the OS does not + // expose the difference without requesting, so call `request()` to find out. } // You can also directly ask permission about its status. @@ -361,14 +363,19 @@ if (await Permission.locationWhenInUse.serviceStatus.isEnabled) { You can also open the app settings: ```dart -if (await Permission.speech.isPermanentlyDenied) { +if (await Permission.speech.request().isPermanentlyDenied) { // The user opted to never again see the permission request dialog for this // app. The only way to change the permission's status now is to let the - // user manually enables it in the system settings. + // user manually enable it in the system settings. openAppSettings(); } ``` +On Android, only the result of `request()` can be `permanentlyDenied`; `status` reports `denied` instead. +Android does not expose whether a permission is permanently denied: a permission that was never requested, one that the user reset to "Ask every time" in the app settings and a permanently denied one all look the same to the app. +Requesting a permanently denied permission is cheap, the OS resolves it immediately without showing a dialog. +The [Android "permanently denied" guide](https://github.com/Baseflow/flutter-permission-handler/blob/main/ANDROID_PERMANENTLY_DENIED_FIX_GUIDE.md) covers this in full: what each status means on Android, the request-driven pattern to use instead, and how to audit an existing app for code that relies on the old behavior. + On Android, you can show a rationale for using permission: ```dart @@ -397,6 +404,10 @@ This will then bring up another permission popup asking you to `Keep Only While ## FAQ +### `Permission.status` never returns `permanentlyDenied` on Android. What can I do? + +That is intentional as of `permission_handler_android` 14.1.0. Android does not expose whether a permission is permanently denied, so a status check reports `denied` for every denied runtime permission. Call `request()` and branch on its result: it returns `permanentlyDenied` without showing a dialog when the permission really is permanently denied. See the [Android "permanently denied" guide](https://github.com/Baseflow/flutter-permission-handler/blob/main/ANDROID_PERMANENTLY_DENIED_FIX_GUIDE.md) for the full behavior table, the pattern to use and an audit checklist for existing apps. + ### Requesting "storage" permissions always returns "denied" on Android 13+. What can I do? On Android, the `Permission.storage` permission is linked to the Android `READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE` permissions. Starting from Android 10 (API 29) the `READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE` permissions have been marked deprecated and have been fully removed/disabled since Android 13 (API 33). diff --git a/permission_handler/pubspec.yaml b/permission_handler/pubspec.yaml index 26c35ae46..4811c78e7 100644 --- a/permission_handler/pubspec.yaml +++ b/permission_handler/pubspec.yaml @@ -2,7 +2,7 @@ name: permission_handler description: Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. repository: https://github.com/baseflow/flutter-permission-handler issue_tracker: https://github.com/Baseflow/flutter-permission-handler/issues -version: 13.0.1 +version: 13.0.2 environment: sdk: ^3.6.0 @@ -25,7 +25,7 @@ dependencies: flutter: sdk: flutter meta: ^1.7.0 - permission_handler_android: ^14.0.0 + permission_handler_android: ^14.1.0 permission_handler_apple: ^9.5.0 permission_handler_html: ^0.1.4+0 permission_handler_windows: ^0.2.2 diff --git a/permission_handler_android/CHANGELOG.md b/permission_handler_android/CHANGELOG.md index b895ea7ed..a017182ba 100644 --- a/permission_handler_android/CHANGELOG.md +++ b/permission_handler_android/CHANGELOG.md @@ -1,3 +1,10 @@ +## 14.1.0 + +- Fixes `Permission.status` reporting `permanentlyDenied` after the user reset a permanently denied permission to "Ask every time" in the Android app settings (Android 11+). See [#1206](https://github.com/Baseflow/flutter-permission-handler/issues/1206). +- **Behavior change:** on Android, `Permission.status` no longer resolves to `permanentlyDenied`. Android does not expose the difference between a permission that was never requested, one reset to "Ask every time" and one that is permanently denied. Only the result of `Permission.request()` can be `permanentlyDenied`; requesting a permanently denied permission resolves immediately without showing a dialog. +- A second denial is now detected from the change of `shouldShowRequestPermissionRationale` across the request, so it is reported as `permanentlyDenied` even when the first denial happened in the app settings. +- Dismissing the very first request dialog is reported as `denied` again instead of `permanentlyDenied`. + ## 14.0.0 - **BREAKING CHANGES:** When updating to version 14.0.0 make sure to also set the `compileSdkVersion` in the `app/build.gradle` file to `37`. diff --git a/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionManager.java b/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionManager.java index 8da9ee2ef..680b8c89d 100644 --- a/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionManager.java +++ b/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionManager.java @@ -59,6 +59,16 @@ final class PermissionManager implements PluginRegistry.ActivityResultListener, * {@link this#requestPermissions(List, RequestPermissionsSuccessCallback, ErrorCallback)}. */ private Map requestResults; + /** + * The output of {@link ActivityCompat#shouldShowRequestPermissionRationale(Activity, String)} + * for every runtime permission requested through + * {@link this#requestPermissions(List, RequestPermissionsSuccessCallback, ErrorCallback)}, + * captured right before the request was made. + *

+ * {@link PermissionUtils#toPermissionStatus(Activity, String, int, Boolean)} compares it with + * the value after the request to detect a permission that just became permanently denied. + */ + private Map shouldShowRationaleBeforeRequest; public PermissionManager(@NonNull Context context) { this.context = context; @@ -187,7 +197,7 @@ public boolean onRequestPermissionsResult( if (calendarWriteIndex >= 0) { final int writeGrantResult = grantResults[calendarWriteIndex]; final @PermissionConstants.PermissionStatus int writeStatus = - PermissionUtils.toPermissionStatus(this.activity, Manifest.permission.WRITE_CALENDAR, writeGrantResult); + toRequestedPermissionStatus(Manifest.permission.WRITE_CALENDAR, writeGrantResult); requestResults.put(PermissionConstants.PERMISSION_GROUP_CALENDAR_WRITE_ONLY, writeStatus); // WRITE + READ -> FULL ACCESS. @@ -195,7 +205,7 @@ public boolean onRequestPermissionsResult( if (calendarReadIndex >= 0) { final int readGrantResult = grantResults[calendarReadIndex]; final @PermissionConstants.PermissionStatus int readStatus = - PermissionUtils.toPermissionStatus(this.activity, Manifest.permission.READ_CALENDAR, readGrantResult); + toRequestedPermissionStatus(Manifest.permission.READ_CALENDAR, readGrantResult); final @PermissionConstants.PermissionStatus int fullAccessStatus = strictestStatus(writeStatus, readStatus); requestResults.put(PermissionConstants.PERMISSION_GROUP_CALENDAR_FULL_ACCESS, fullAccessStatus); // Support deprecated CALENDAR permission. @@ -221,30 +231,30 @@ public boolean onRequestPermissionsResult( if (permission == PermissionConstants.PERMISSION_GROUP_PHONE) { @Nullable @PermissionConstants.PermissionStatus Integer previousResult = requestResults.get(PermissionConstants.PERMISSION_GROUP_PHONE); - @PermissionConstants.PermissionStatus int newResult = PermissionUtils.toPermissionStatus(this.activity, permissionName, result); + @PermissionConstants.PermissionStatus int newResult = toRequestedPermissionStatus(permissionName, result); @Nullable @PermissionConstants.PermissionStatus Integer strictestStatus = strictestStatus(previousResult, newResult); requestResults.put(PermissionConstants.PERMISSION_GROUP_PHONE, strictestStatus); } else if (permission == PermissionConstants.PERMISSION_GROUP_MICROPHONE) { if (!requestResults.containsKey(PermissionConstants.PERMISSION_GROUP_MICROPHONE)) { requestResults.put( PermissionConstants.PERMISSION_GROUP_MICROPHONE, - PermissionUtils.toPermissionStatus(this.activity, permissionName, result)); + toRequestedPermissionStatus(permissionName, result)); } if (!requestResults.containsKey(PermissionConstants.PERMISSION_GROUP_SPEECH)) { requestResults.put( PermissionConstants.PERMISSION_GROUP_SPEECH, - PermissionUtils.toPermissionStatus(this.activity, permissionName, result)); + toRequestedPermissionStatus(permissionName, result)); } } else if (permission == PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS) { @PermissionConstants.PermissionStatus int permissionStatus = - PermissionUtils.toPermissionStatus(this.activity, permissionName, result); + toRequestedPermissionStatus(permissionName, result); if (!requestResults.containsKey(PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS)) { requestResults.put(PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS, permissionStatus); } } else if (permission == PermissionConstants.PERMISSION_GROUP_LOCATION) { @PermissionConstants.PermissionStatus int permissionStatus = - PermissionUtils.toPermissionStatus(this.activity, permissionName, result); + toRequestedPermissionStatus(permissionName, result); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (!requestResults.containsKey(PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS)) { @@ -264,13 +274,17 @@ public boolean onRequestPermissionsResult( // [grantResults] can only contain PermissionConstants.PERMISSION_STATUS_GRANTED or PermissionConstants.PERMISSION_STATUS_DENIED status. // But these permissions can have status PermissionConstants.PERMISSION_STATUS_LIMITED, so we need to recheck status } else if (permission == PermissionConstants.PERMISSION_GROUP_PHOTOS || permission == PermissionConstants.PERMISSION_GROUP_VIDEOS) { - requestResults.put( - permission, - determinePermissionStatus(permission)); + // A status check cannot tell 'denied' from 'permanently denied', so only reuse it + // for the 'granted' and 'limited' statuses and resolve a denial from the request. + @PermissionConstants.PermissionStatus int permissionStatus = determinePermissionStatus(permission); + if (permissionStatus == PermissionConstants.PERMISSION_STATUS_DENIED) { + permissionStatus = toRequestedPermissionStatus(permissionName, result); + } + requestResults.put(permission, permissionStatus); } else if (!requestResults.containsKey(permission)) { requestResults.put( permission, - PermissionUtils.toPermissionStatus(this.activity, permissionName, result)); + toRequestedPermissionStatus(permissionName, result)); } } @@ -286,10 +300,14 @@ public boolean onRequestPermissionsResult( /** * Determines the permission status of the provided permission. *

- * To distinguish between a status of 'denied' and a status of 'permanently denied', the plugin - * needs access to an activity. If `this.activity` is null, for example when running the - * application in the background, the resolved status will be 'denied' for both 'denied' and - * 'permanently denied'. + * On Android a status check can never resolve to 'permanently denied': the OS reports a + * permanently denied runtime permission exactly like one that was never requested, or one that + * the user reset to 'Ask every time' in the app settings (Android 11+). A denied runtime + * permission is therefore always reported as 'denied'. Only + * {@link this#requestPermissions(List, RequestPermissionsSuccessCallback, ErrorCallback)} can + * resolve to 'permanently denied', see + * {@link PermissionUtils#toPermissionStatus(Activity, String, int, Boolean)}. Requesting a + * permanently denied permission is cheap as the OS resolves it immediately without a dialog. * * @param permission the permission for which to determine the status. * @param successCallback the callback to which the resolved status must be supplied. @@ -350,6 +368,7 @@ void requestPermissions( this.successCallback = successCallback; this.requestResults = new HashMap<>(); + this.shouldShowRationaleBeforeRequest = new HashMap<>(); this.pendingRequestCount = 0; // sanity check ArrayList permissionsToRequest = new ArrayList<>(); @@ -431,6 +450,13 @@ void requestPermissions( // Request runtime permissions. if (permissionsToRequest.size() > 0) { final String[] requestPermissions = permissionsToRequest.toArray(new String[0]); + // Capture whether the rationale should be shown before requesting. See + // PermissionUtils.toPermissionStatus for how this is used to resolve the request result. + for (String permissionName : requestPermissions) { + shouldShowRationaleBeforeRequest.put( + permissionName, + ActivityCompat.shouldShowRequestPermissionRationale(activity, permissionName)); + } ActivityCompat.requestPermissions( activity, requestPermissions, @@ -443,6 +469,26 @@ void requestPermissions( } } + /** + * Resolves the status of a runtime permission from the result of a permission request. + * + * @param permissionName the name of the requested permission. + * @param grantResult the grant result reported by the OS for this permission. + * @return the resolved permission status, see + * {@link PermissionUtils#toPermissionStatus(Activity, String, int, Boolean)}. + */ + @PermissionConstants.PermissionStatus + private int toRequestedPermissionStatus(final String permissionName, final int grantResult) { + final Boolean shouldShowRationaleBefore = shouldShowRationaleBeforeRequest == null + ? null + : shouldShowRationaleBeforeRequest.get(permissionName); + return PermissionUtils.toPermissionStatus( + activity, + permissionName, + grantResult, + shouldShowRationaleBefore); + } + @PermissionConstants.PermissionStatus private int determinePermissionStatus(final @PermissionConstants.PermissionGroup int permission) { @@ -562,14 +608,19 @@ private int determinePermissionStatus(final @PermissionConstants.PermissionGroup if (permissionStatusLimited == PackageManager.PERMISSION_GRANTED && permissionStatus == PackageManager.PERMISSION_DENIED) { permissionStatuses.add(PermissionConstants.PERMISSION_STATUS_LIMITED); } else if (permissionStatus == PackageManager.PERMISSION_GRANTED) { + PermissionUtils.clearPermissionDenied(context, name); permissionStatuses.add(PermissionConstants.PERMISSION_STATUS_GRANTED); - }else { - permissionStatuses.add(PermissionUtils.determineDeniedVariant(activity, name)); + } else { + // See checkPermissionStatus: a status check cannot detect 'permanently denied'. + permissionStatuses.add(PermissionConstants.PERMISSION_STATUS_DENIED); } - }else { + } else { final int permissionStatus = ContextCompat.checkSelfPermission(context, name); - if (permissionStatus != PackageManager.PERMISSION_GRANTED) { - permissionStatuses.add(PermissionUtils.determineDeniedVariant(activity, name)); + if (permissionStatus == PackageManager.PERMISSION_GRANTED) { + PermissionUtils.clearPermissionDenied(context, name); + } else { + // See checkPermissionStatus: a status check cannot detect 'permanently denied'. + permissionStatuses.add(PermissionConstants.PERMISSION_STATUS_DENIED); } } } @@ -649,9 +700,11 @@ private int checkNotificationPermissionStatus() { final int status = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS); if (status == PackageManager.PERMISSION_GRANTED) { + PermissionUtils.clearPermissionDenied(context, Manifest.permission.POST_NOTIFICATIONS); return PermissionConstants.PERMISSION_STATUS_GRANTED; } - return PermissionUtils.determineDeniedVariant(activity, Manifest.permission.POST_NOTIFICATIONS); + // See checkPermissionStatus: a status check cannot detect 'permanently denied'. + return PermissionConstants.PERMISSION_STATUS_DENIED; } @PermissionConstants.PermissionStatus diff --git a/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionUtils.java b/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionUtils.java index 35cc35e2d..14ab4e9fc 100644 --- a/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionUtils.java +++ b/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionUtils.java @@ -12,7 +12,6 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import androidx.core.app.ActivityCompat; import java.util.ArrayList; import java.util.Arrays; @@ -678,12 +677,14 @@ private static boolean hasPermissionInManifest( } /** - * Returns a {@link PermissionConstants} for a given permission. + * Converts the result of a runtime permission request into a + * {@link PermissionConstants.PermissionStatus}. *

- * When {@link PackageManager#PERMISSION_DENIED} is received, we do not know if the permission was - * denied permanently. The OS does not tell us whether the user dismissed the dialog or pressed - * 'deny'. Therefore, we need a more sophisticated (read: hacky) approach to determine whether the - * permission status is {@link PermissionConstants#PERMISSION_STATUS_DENIED} or + * When {@link PackageManager#PERMISSION_DENIED} is received, the OS does not tell us whether the + * user denied the request, dismissed the dialog, or whether the dialog was never shown because + * the permission is permanently denied. Therefore, we need a more sophisticated (read: hacky) + * approach to determine whether the permission status is + * {@link PermissionConstants#PERMISSION_STATUS_DENIED} or * {@link PermissionConstants#PERMISSION_STATUS_NEVER_ASK_AGAIN}. *

* The OS behavior has been researched experimentally and is displayed in the following diagrams: @@ -700,37 +701,63 @@ private static boolean hasPermissionInManifest( * │ │ * ┌─▼────────┴┐ ┌────────────────────────────────┐ * │Denied once├────────►Denied twice(permanently denied)│ - * └──▲┌───────┘ Denied └────────────────────────────────┘ - * └┘ - * Dismissed + * └──▲┌───────┘ Denied └──┬─────────────────────────────┘ + * └┘ │ 'Ask every time' in the app settings (Android 11+) + * Dismissed ▼ + * ┌──────────────┐ + * │Ask every time│ (looks exactly like 'Not asked' to the app) + * └──────────────┘ *

* Scenario table listing output of * {@link ActivityCompat#shouldShowRequestPermissionRationale(Activity, String)}: - * ┌────────────┬────────────────┬─────────┬───────────────────────────────────┬─────────────────────────┐ - * │ Scenario # │ Previous state │ Action │ New state │ 'Show rationale' output │ - * ├────────────┼────────────────┼─────────┼───────────────────────────────────┼─────────────────────────┤ - * │ 1. │ Not asked │ Dismiss │ Not asked │ false │ - * │ 2. │ Not asked │ Deny │ Denied once │ true │ - * │ 3. │ Denied once │ Dismiss │ Denied once │ true │ - * │ 4. │ Denied once │ Deny │ Denied twice (permanently denied) │ false │ - * └────────────┴────────────────┴─────────┴───────────────────────────────────┴─────────────────────────┘ + * ┌────────────┬─────────────────────────────┬────────────────────────────┬───────────────────────────────────┬─────────────────────────┐ + * │ Scenario # │ Previous state │ Action │ New state │ 'Show rationale' output │ + * ├────────────┼─────────────────────────────┼────────────────────────────┼───────────────────────────────────┼─────────────────────────┤ + * │ 1. │ Not asked │ Dismiss │ Not asked │ false │ + * │ 2. │ Not asked │ Deny │ Denied once │ true │ + * │ 3. │ Denied once │ Dismiss │ Denied once │ true │ + * │ 4. │ Denied once │ Deny │ Denied twice (permanently denied) │ false │ + * │ 5. │ Denied twice │ Request (no dialog shown) │ Denied twice │ false │ + * │ 6. │ Granted / Ask every time │ 'Don't allow' in settings │ Denied once │ true │ + * │ 7. │ Any │ 'Ask every time' in │ Ask every time │ false │ + * │ │ │ settings (Android 11+) │ │ │ + * │ 8. │ Ask every time │ Dismiss │ Ask every time │ false │ + * │ 9. │ Ask every time │ Deny │ Denied once │ true │ + * └────────────┴─────────────────────────────┴────────────────────────────┴───────────────────────────────────┴─────────────────────────┘ + *

+ * Selecting 'Ask every time' makes the OS revoke the permission as a one-time permission, which + * clears the {@code FLAG_PERMISSION_USER_SET} flag. As 'show rationale' is exactly that flag + * (as long as the permission is not user fixed), scenarios 1, 4, 5, 7 and 8 all produce + * {@code false} and no public API exposes the difference between them. *

- * To distinguish between scenarios, we can use - * {@link ActivityCompat#shouldShowRequestPermissionRationale(Activity, String)}. If it returns - * true, we can safely return {@link PermissionConstants#PERMISSION_STATUS_DENIED}. To distinguish - * between scenarios 1 and 4, however, we need an extra mechanism. We opt to store a boolean - * stating whether permission has been requested before. Using a combination of checking for - * showing the permission rationale and the boolean, we can distinguish all scenarios and return - * the appropriate permission status. + * A denied request result is therefore resolved from three signals: + *

    + *
  • 'Show rationale' after the request is {@code true} (scenarios 2, 3 and 9): the user can + * be asked again, resolve to 'denied'.
  • + *
  • 'Show rationale' was {@code true} before the request and is {@code false} after it + * (scenario 4): the user denied for the second time, resolve to 'permanently denied'.
  • + *
  • 'Show rationale' was {@code false} before and after the request and the permission was + * denied before (stored in {@link SharedPreferences}, see + * {@link #wasPermissionDeniedBefore(Context, String)}): the OS resolved the request without + * showing a dialog (scenario 5), resolve to 'permanently denied'. A dismissed dialog after the + * user selected 'Ask every time' (scenario 8) is indistinguishable and resolves the same way; + * the next request will show the dialog again.
  • + *
  • Otherwise the user dismissed the very first request (scenario 1), resolve to 'denied'.
  • + *
*

- * Changing permissions via the app info screen, so outside of the application, changes the - * permission state to 'Granted' if the permission is allowed, or 'Denied once' if denied. This - * behavior should not require any additional logic. + * Note that a permission status check without a request can never resolve to 'permanently + * denied' on Android for the same reason, see + * {@link PermissionManager#checkPermissionStatus(int, PermissionManager.CheckPermissionsSuccessCallback)}. * - * @param activity the activity for context - * @param permissionName the name of the permission - * @param grantResult the result of the permission intent. Either - * {@link PackageManager#PERMISSION_DENIED} or {@link PackageManager#PERMISSION_GRANTED}. + * @param activity the activity for context + * @param permissionName the name of the permission + * @param grantResult the result of the permission request. Either + * {@link PackageManager#PERMISSION_DENIED} or + * {@link PackageManager#PERMISSION_GRANTED}. + * @param shouldShowRationaleBeforeRequest the output of + * {@link ActivityCompat#shouldShowRequestPermissionRationale(Activity, String)} + * captured right before the request was made, or + * {@code null} when unknown. * @return {@link PermissionConstants#PERMISSION_STATUS_GRANTED}, * {@link PermissionConstants#PERMISSION_STATUS_DENIED}, or * {@link PermissionConstants#PERMISSION_STATUS_NEVER_ASK_AGAIN}. @@ -739,13 +766,44 @@ private static boolean hasPermissionInManifest( static int toPermissionStatus( final @Nullable Activity activity, final String permissionName, - int grantResult + final int grantResult, + final @Nullable Boolean shouldShowRationaleBeforeRequest ) { - if (grantResult == PackageManager.PERMISSION_DENIED) { - return determineDeniedVariant(activity, permissionName); + if (grantResult != PackageManager.PERMISSION_DENIED) { + if (activity != null) { + clearPermissionDenied(activity, permissionName); + } + return PermissionConstants.PERMISSION_STATUS_GRANTED; } - return PermissionConstants.PERMISSION_STATUS_GRANTED; + if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return PermissionConstants.PERMISSION_STATUS_DENIED; + } + + final boolean shouldShowRationaleAfterRequest = + ActivityCompat.shouldShowRequestPermissionRationale(activity, permissionName); + + if (shouldShowRationaleAfterRequest) { + // Scenarios 2, 3 and 9: the OS will show the dialog again on the next request. + setPermissionDenied(activity, permissionName); + return PermissionConstants.PERMISSION_STATUS_DENIED; + } + + if (Boolean.TRUE.equals(shouldShowRationaleBeforeRequest)) { + // Scenario 4: the user denied for the second time (or ticked 'Don't ask again' on + // Android 10 and below). + setPermissionDenied(activity, permissionName); + return PermissionConstants.PERMISSION_STATUS_NEVER_ASK_AGAIN; + } + + if (wasPermissionDeniedBefore(activity, permissionName)) { + // Scenario 5 (and 8): the OS did not show a dialog because the permission is + // permanently denied. + return PermissionConstants.PERMISSION_STATUS_NEVER_ASK_AGAIN; + } + + // Scenario 1: the user dismissed the very first request without making a choice. + return PermissionConstants.PERMISSION_STATUS_DENIED; } @NonNull @@ -786,60 +844,6 @@ static Integer strictestStatus( return strictestStatus(statuses); } - /** - * Determines whether a permission was either 'denied' or 'permanently denied'. - *

- * To distinguish between these two variants, the method needs access to an {@link Activity}. - * If the provided activity is null, the result will always be resolved to 'denied'. - * - * @param activity the activity needed to resolve the permission status. - * @param permissionName the name of the permission. - * @return either {@link PermissionConstants#PERMISSION_STATUS_DENIED} or - * {@link PermissionConstants#PERMISSION_STATUS_NEVER_ASK_AGAIN}. - */ - @PermissionConstants.PermissionStatus - static int determineDeniedVariant( - final @Nullable Activity activity, - final String permissionName - ) { - if (activity == null) { - return PermissionConstants.PERMISSION_STATUS_DENIED; - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return PermissionConstants.PERMISSION_STATUS_DENIED; - } - - final boolean wasDeniedBefore = - PermissionUtils.wasPermissionDeniedBefore(activity, permissionName); - final boolean shouldShowRational = - !PermissionUtils.isNeverAskAgainSelected(activity, permissionName); - - //noinspection SimplifiableConditionalExpression - final boolean isDenied = wasDeniedBefore - ? !shouldShowRational - : shouldShowRational; - - if (!wasDeniedBefore && isDenied) { - setPermissionDenied(activity, permissionName); - } - - if (wasDeniedBefore && isDenied) { - return PermissionConstants.PERMISSION_STATUS_NEVER_ASK_AGAIN; - } - - return PermissionConstants.PERMISSION_STATUS_DENIED; - } - - @RequiresApi(api = Build.VERSION_CODES.M) - static boolean isNeverAskAgainSelected( - @NonNull final Activity activity, - final String name - ) { - final boolean shouldShowRequestPermissionRationale = - ActivityCompat.shouldShowRequestPermissionRationale(activity, name); - return !shouldShowRequestPermissionRationale; - } private static String determineBluetoothPermission( Context context, @@ -953,4 +957,35 @@ private static void setPermissionDenied( ) .apply(); } + + /** + * Removes the {@link SharedPreferences} flag that marks the provided permission as denied + * before, if present. + *

+ * Called whenever the permission is observed to be granted, so that a later denial is tracked + * from a clean slate. Without this, a permission that was denied once, granted in the app + * settings and then reset to 'Ask every time' would resolve to 'permanently denied' when the + * user dismisses the next request dialog. + * + * @param context context needed for accessing shared preferences. + * @param permissionName the name of the permission + */ + static void clearPermissionDenied( + final Context context, + final String permissionName + ) { + final SharedPreferences sharedPreferences = + context.getSharedPreferences(permissionName, Context.MODE_PRIVATE); + if ( + !sharedPreferences.contains( + SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY + ) + ) { + return; + } + sharedPreferences + .edit() + .remove(SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY) + .apply(); + } } diff --git a/permission_handler_android/pubspec.yaml b/permission_handler_android/pubspec.yaml index 725632701..dcae7813d 100644 --- a/permission_handler_android/pubspec.yaml +++ b/permission_handler_android/pubspec.yaml @@ -1,7 +1,7 @@ name: permission_handler_android description: Permission plugin for Flutter. This plugin provides the Android API to request and check permissions. homepage: https://github.com/baseflow/flutter-permission-handler -version: 14.0.0 +version: 14.1.0 environment: sdk: ^3.6.0 diff --git a/permission_handler_platform_interface/CHANGELOG.md b/permission_handler_platform_interface/CHANGELOG.md index c94bf582a..2d1d1a0b3 100644 --- a/permission_handler_platform_interface/CHANGELOG.md +++ b/permission_handler_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.4.1 + +- Documents that on Android `PermissionStatus.permanentlyDenied` is only returned as the result of a request, never by a status check. + ## 4.4.0 - Adds support for the new Android 17 permission `ACCESS_LOCAL_NETWORK` diff --git a/permission_handler_platform_interface/lib/src/permission_status.dart b/permission_handler_platform_interface/lib/src/permission_status.dart index 29e9c94f9..038fd944f 100644 --- a/permission_handler_platform_interface/lib/src/permission_status.dart +++ b/permission_handler_platform_interface/lib/src/permission_status.dart @@ -27,10 +27,15 @@ enum PermissionStatus { /// still change the permission status in the settings. /// /// *On Android:* - /// Android 11+ (API 30+): whether the user denied the permission for a second - /// time. - /// Below Android 11 (API 30): whether the user denied access to the requested - /// feature and selected to never again show a request. + /// Only returned as the result of a request, never by a status check. Android + /// offers no API to tell a permanently denied permission apart from one that + /// was never requested or that the user reset to 'Ask every time' in the app + /// settings, so `status` reports `denied` for all of them. Requesting a + /// permanently denied permission resolves immediately, without showing a + /// dialog, to this status. + /// Android 11+ (API 30+): the user denied the permission for a second time. + /// Below Android 11 (API 30): the user denied access to the requested feature + /// and selected to never again show a request. /// /// *On iOS:* /// If the user has denied access to the requested feature. @@ -96,11 +101,15 @@ extension PermissionStatusGetters on PermissionStatus { /// user may still change the permission status in the settings. /// /// *On Android:* - /// Android 11+ (API 30+): whether the user denied the permission for a second - /// time. - /// Below Android 11 (API 30): whether the user denied access to the requested - /// feature and selected to never again show a request. - /// The user may still change the permission status in the settings. + /// Only returned as the result of a request, never by a status check. Android + /// offers no API to tell a permanently denied permission apart from one that + /// was never requested or that the user reset to 'Ask every time' in the app + /// settings, so `status` reports `denied` for all of them. Requesting a + /// permanently denied permission resolves immediately, without showing a + /// dialog, to this status. + /// Android 11+ (API 30+): the user denied the permission for a second time. + /// Below Android 11 (API 30): the user denied access to the requested feature + /// and selected to never again show a request. /// /// *On iOS:* /// If the user has denied access to the requested feature. @@ -139,10 +148,15 @@ extension FuturePermissionStatusGetters on Future { /// user may still change the permission status in the settings. /// /// *On Android:* - /// Android 11+ (API 30+): whether the user denied the permission for a second - /// time. - /// Below Android 11 (API 30): whether the user denied access to the requested - /// feature and selected to never again show a request. + /// Only returned as the result of a request, never by a status check. Android + /// offers no API to tell a permanently denied permission apart from one that + /// was never requested or that the user reset to 'Ask every time' in the app + /// settings, so `status` reports `denied` for all of them. Requesting a + /// permanently denied permission resolves immediately, without showing a + /// dialog, to this status. + /// Android 11+ (API 30+): the user denied the permission for a second time. + /// Below Android 11 (API 30): the user denied access to the requested feature + /// and selected to never again show a request. /// /// *On iOS:* /// If the user has denied access to the requested feature. diff --git a/permission_handler_platform_interface/pubspec.yaml b/permission_handler_platform_interface/pubspec.yaml index cbc9afddd..88a95a917 100644 --- a/permission_handler_platform_interface/pubspec.yaml +++ b/permission_handler_platform_interface/pubspec.yaml @@ -3,7 +3,7 @@ description: A common platform interface for the permission_handler plugin. homepage: https://github.com/baseflow/flutter-permission-handler/tree/master/permission_handler_platform_interface # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 4.4.0 +version: 4.4.1 environment: sdk: ^3.6.0