diff --git a/.github/workflows/permission_handler.yaml b/.github/workflows/permission_handler.yaml index 340849441..99f138b23 100644 --- a/.github/workflows/permission_handler.yaml +++ b/.github/workflows/permission_handler.yaml @@ -26,7 +26,7 @@ jobs: # TODO(mvanbeusekom): Manually set to macOS 15 to support Xcode 16 and iOS 18 SDKs. # Currently `macos-latest` is based on macOS 14 and doesn't support iOS 18 SDK. This # should be moved back to `macos-latest` when GitHub Actions images are updated. - runs-on: macos-15 + runs-on: macos-15 env: source-directory: ./permission_handler @@ -42,7 +42,6 @@ jobs: with: distribution: "temurin" # See 'Supported distributions' for available options java-version: "17" - # Make sure the stable version of Flutter is available - uses: subosito/flutter-action@v2 with: @@ -62,18 +61,7 @@ jobs: - name: Run Flutter Analyzer run: flutter analyze working-directory: ${{env.source-directory}} - - # Build Android version of the example App - - name: Run Android build - run: flutter build apk --release - working-directory: ${{env.example-directory}} - - # Build iOS version of the example App - - name: Run iOS build - run: flutter build ios --release --no-codesign - working-directory: ${{env.example-directory}} - - # Run all unit-tests with code coverage + # Run all unit-tests with code coverage - name: Run unit tests run: flutter test --coverage working-directory: ${{env.source-directory}} @@ -85,3 +73,13 @@ jobs: file: ${{env.source-directory}}/coverage/lcov.info # optional flags: unittests # optional name: permission_handler # optional + + # Build Android version of the example App + - name: Run Android build + run: flutter build apk --release + working-directory: ${{env.example-directory}} + + # Build iOS version of the example App + - name: Run iOS build + run: flutter build ios --release --no-codesign + working-directory: ${{env.example-directory}} diff --git a/.github/workflows/permission_handler_android.yaml b/.github/workflows/permission_handler_android.yaml index 0bbe16316..547ff265f 100644 --- a/.github/workflows/permission_handler_android.yaml +++ b/.github/workflows/permission_handler_android.yaml @@ -6,15 +6,15 @@ name: permission_handler_android # events but only for the main branch on: push: - branches: [ main ] + branches: [main] paths: - - 'permission_handler_android/**' - - '.github/workflows/permission_handler_android.yaml' + - "permission_handler_android/**" + - ".github/workflows/permission_handler_android.yaml" pull_request: - branches: [ main ] + branches: [main] paths: - - 'permission_handler_android/**' - - '.github/workflows/permission_handler_android.yaml' + - "permission_handler_android/**" + - ".github/workflows/permission_handler_android.yaml" # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: @@ -26,7 +26,7 @@ jobs: env: source-directory: ./permission_handler_android - example-directory: ./permission_handler_android/example + example-directory: ./permission_handler_android/example # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -36,13 +36,12 @@ jobs: # Make sure JAVA version 17 is installed on build agent. - uses: actions/setup-java@v3 with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '17' - + distribution: "temurin" # See 'Supported distributions' for available options + java-version: "17" # Make sure the stable version of Flutter is available - uses: subosito/flutter-action@v2 with: - channel: 'stable' + channel: "stable" # Download all Flutter packages - name: Download dependencies @@ -63,4 +62,3 @@ jobs: - name: Run Android build run: flutter build apk --release working-directory: ${{env.example-directory}} - \ No newline at end of file diff --git a/.github/workflows/permission_handler_apple.yaml b/.github/workflows/permission_handler_apple.yaml index 5540a042c..5be3cb6e0 100644 --- a/.github/workflows/permission_handler_apple.yaml +++ b/.github/workflows/permission_handler_apple.yaml @@ -57,7 +57,153 @@ jobs: run: flutter analyze working-directory: ${{env.source-directory}} - # Build iOS version of the example App + # Build iOS version of the example App. + # + # The example resolves this plugin as a Swift package (the Podfile only + # covers its remaining CocoaPods dependencies), so the "Verify + # permission_handler flavor" build phase is active and `strict: true` + # demands a selection before a Release build can run. `select` also + # generates ios/Flutter/permission_handler.resolved.json from + # permission_handler.yaml — neither it nor the selection file is + # committed, so every CI run has to make its own. - name: Run iOS build - run: flutter build ios --no-codesign --release working-directory: ${{env.example-directory}} + run: | + set -euo pipefail + dart run permission_handler_apple:select release + flutter build ios --no-codesign --release + + # Guard the Swift Package Manager permission resolution. + # + # Package.swift turns the usage description keys of the host app's + # Info.plist into PERMISSION_* macros. When that breaks, every macro + # silently falls back to 0 and all permissions are compiled out, so assert + # on the resolved manifest rather than on the build succeeding. + # + # The example declares two flavors over two Info.plist files that differ by + # exactly one key: `debug` (Info-Debug.plist, the build-configuration + # specific layout reported in issue #1548) has no contacts entry, + # `release` (Info.plist) does. That single difference is what proves both + # that a flavor cannot inherit another flavor's permissions and, on the + # legacy path, that both plists were discovered and merged. + - name: Verify SPM permission resolution + working-directory: ${{env.example-directory}} + run: | + set -euo pipefail + flutter config --enable-swift-package-manager + flutter build ios --config-only --debug --no-codesign + + # Resolve the manifest under a given environment, capturing the macros + # (stdout) and the PERMISSION_HANDLER_VERBOSE diagnostics (stderr). + resolve() { # ... + local name="$1"; shift + rm -rf ~/Library/Caches/org.swift.swiftpm/manifests + env PERMISSION_HANDLER_VERBOSE=1 "$@" swift package --manifest-cache none \ + --package-path ios/Flutter/ephemeral/Packages/.packages/permission_handler_apple \ + dump-package > "${RUNNER_TEMP}/${name}.json" 2> "${RUNNER_TEMP}/${name}.log" + echo "--- ${name} ---" + cat "${RUNNER_TEMP}/${name}.log" + } + + expect() { # ... + local name="$1"; shift + for macro in "$@"; do + grep -qF "${macro}" "${RUNNER_TEMP}/${name}.json" || { + echo "::error::[${name}] expected ${macro} in the resolved manifest" + grep -o 'PERMISSION_[A-Z_]*=[01]' "${RUNNER_TEMP}/${name}.json" | sort -u + exit 1 + } + done + } + + expect_log() { # + grep -qF "$2" "${RUNNER_TEMP}/$1.log" || { echo "::error::[$1] $3"; exit 1; } + } + + # Without a permission_handler.yaml the pre-flavor behaviour has to be + # unchanged: discover every Info.plist and merge them. PERMISSION_CONTACTS + # can only be 1 if both plists were found, which is what makes this + # assert on the discovery rather than on the example's default plist. + resolve legacy PERMISSION_HANDLER_CONFIG=/nonexistent/permission_handler.yaml + expect_log legacy 'Runner/Info.plist' \ + 'ios/Runner/Info.plist was not discovered' + expect_log legacy 'Runner/Info-Debug.plist' \ + 'ios/Runner/Info-Debug.plist was not discovered — INFOPLIST_FILE parsing is broken' + expect_log legacy 'NSContactsUsageDescription' \ + 'the divergence warning did not fire — the plists were not merged' + expect legacy PERMISSION_CONTACTS=1 PERMISSION_CAMERA=1 PERMISSION_MICROPHONE=1 \ + PERMISSION_PHOTOS=1 PERMISSION_LOCATION=1 + + # The debug flavor must not see the release flavor's contacts entry. + resolve debug PERMISSION_HANDLER_FLAVOR=debug + expect debug PERMISSION_CONTACTS=0 PERMISSION_CAMERA=1 PERMISSION_MICROPHONE=1 + + # ...and the release flavor must see it. + resolve release PERMISSION_HANDLER_FLAVOR=release + expect release PERMISSION_CONTACTS=1 PERMISSION_CAMERA=1 PERMISSION_MICROPHONE=1 + + # With `strict: true` an unresolved flavor compiles nothing in, + # rather than falling back to the union of every flavor. The earlier + # build step recorded a selection, so drop it first — otherwise the + # manifest resolves that flavor and this asserts nothing. The + # generated resolved.json stays; only the selection is missing. + rm -f ios/Flutter/permission_handler.selected + resolve strict + expect strict PERMISSION_CONTACTS=0 PERMISSION_CAMERA=0 PERMISSION_MICROPHONE=0 + + # Editing permission_handler.yaml without re-running `select` must not + # build with the stale translation: everything compiles out and the + # manifest says to re-run select. + touch permission_handler.yaml + resolve stale PERMISSION_HANDLER_FLAVOR=release + expect stale PERMISSION_CONTACTS=0 PERMISSION_CAMERA=0 PERMISSION_MICROPHONE=0 + expect_log stale 'modified after its generated translation' \ + 'the staleness guard did not fire' + + # An app target whose INFOPLIST_FILE is built from a variable this manifest + # does not expand, next to an extension carrying Xcode's stock + # `GENERATE_INFOPLIST_FILE = YES`. Neither yields a usable path, so the + # scan of `ios/` has to take over; a settings hit that resolves to nothing + # must not suppress it. Built here rather than committed, because it is a + # project layout the example cannot also be. + - name: Verify SPM detection falls back to scanning + run: | + set -euo pipefail + app="${RUNNER_TEMP}/fallback-app" + rm -rf "${app}" + mkdir -p "${app}/ios/Extension.xcodeproj" "${app}/ios/Runner" + touch "${app}/pubspec.yaml" + cp "${{env.example-directory}}/ios/Runner/Info.plist" "${app}/ios/Runner/Info.plist" + printf 'GENERATE_INFOPLIST_FILE = YES;\nINFOPLIST_FILE = "$(TARGET_NAME)/Info.plist";\n' \ + > "${app}/ios/Extension.xcodeproj/project.pbxproj" + + cd "${app}" + rm -rf ~/Library/Caches/org.swift.swiftpm/manifests + PERMISSION_HANDLER_VERBOSE=1 swift package --manifest-cache none \ + --package-path "${GITHUB_WORKSPACE}/permission_handler_apple/ios/permission_handler_apple" \ + dump-package > "${RUNNER_TEMP}/fallback.json" 2> "${RUNNER_TEMP}/fallback.log" + cat "${RUNNER_TEMP}/fallback.log" + + grep -qF 'PERMISSION_CAMERA=1' "${RUNNER_TEMP}/fallback.json" || { + echo "::error::the scan fallback did not run — an unresolvable INFOPLIST_FILE suppressed it" + exit 1 + } + + # The build phase that catches a stale selection is the only check that + # runs on every build, so make sure it actually fails. + - name: Verify flavor mismatch fails the build + working-directory: ${{env.example-directory}} + run: | + set -euo pipefail + dart run permission_handler_apple:select release + if xcodebuild -workspace ios/Runner.xcworkspace -scheme Runner \ + -configuration Debug -sdk iphonesimulator \ + -derivedDataPath "${RUNNER_TEMP}/dd" build > "${RUNNER_TEMP}/mismatch.log" 2>&1; then + echo "::error::a Debug build with the release flavor selected should have failed" + exit 1 + fi + grep -q 'needs the "debug" permission flavor' "${RUNNER_TEMP}/mismatch.log" || { + echo "::error::build failed, but not with the flavor mismatch error" + tail -40 "${RUNNER_TEMP}/mismatch.log" + exit 1 + } diff --git a/.github/workflows/permission_handler_platform_interface.yaml b/.github/workflows/permission_handler_platform_interface.yaml index 4f6408b78..46344a92b 100644 --- a/.github/workflows/permission_handler_platform_interface.yaml +++ b/.github/workflows/permission_handler_platform_interface.yaml @@ -6,15 +6,15 @@ name: permission_handler_platform_interface # events but only for the main branch on: push: - branches: [ main ] + branches: [main] paths: - - 'permission_handler_platform_interface/**' - - '.github/workflows/permission_handler_platform_interface.yaml' + - "permission_handler_platform_interface/**" + - ".github/workflows/permission_handler_platform_interface.yaml" pull_request: - branches: [ main ] + branches: [main] paths: - - 'permission_handler_platform_interface/**' - - '.github/workflows/permission_handler_platform_interface.yaml' + - "permission_handler_platform_interface/**" + - ".github/workflows/permission_handler_platform_interface.yaml" # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: @@ -25,17 +25,17 @@ jobs: runs-on: ubuntu-latest env: - source-directory: ./permission_handler_platform_interface + source-directory: ./permission_handler_platform_interface # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v3 - + # Make sure the stable version of Flutter is available - uses: subosito/flutter-action@v2 with: - channel: 'stable' + channel: "stable" # Download all Flutter packages - name: Download dependencies @@ -46,20 +46,21 @@ jobs: - name: Run Dart Format run: dart format --set-exit-if-changed . working-directory: ${{env.source-directory}} - + # Run Flutter Analyzer - name: Run Flutter Analyzer run: flutter analyze working-directory: ${{env.source-directory}} - + # Run all unit-tests with code coverage - name: Run unit tests run: flutter test --coverage working-directory: ${{env.source-directory}} # Upload code coverage information - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v3 with: + token: ${{ secrets.CODECOV_TOKEN }} file: ${{env.source-directory}}/coverage/lcov.info # optional name: permission_handler_platform_interface (Platform Interface Package) # optional - fail_ci_if_error: true \ No newline at end of file + flags: unittests diff --git a/.gitignore b/.gitignore index 161ad2bb3..534dbdf8b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ .project .svn/ bin/ +# ...except packages that ship an executable, which pub requires to live in bin/. +!permission_handler_apple/bin/ migrate_working_dir/ # IntelliJ related 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 576f07f1b..46b35adb6 100644 --- a/permission_handler/CHANGELOG.md +++ b/permission_handler/CHANGELOG.md @@ -1,3 +1,27 @@ +## 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. + +## 13.0.0 + +- **BREAKING CHANGE:** , android compilesdk now set to version `compileSdkVersion 37` +- Added support for the new Android 17 permission `ACCESS_LOCAL_NETWORK` + +## 12.0.3 + +- Rewrites the "iOS - CocaoPods" section in the README.md to match version 8.0.0 of the permission_handler. +- Fixes several smaller typo's in the README.md. + +## 12.0.2 + +- Updates the documentation to include instructions on Swift Package Manager (SPM) support. + ## 12.0.1 - Updates the correspondence between permission groups and the key values of Info.plist in the README.md. diff --git a/permission_handler/README.md b/permission_handler/README.md index 836bd1339..b47df7b6a 100644 --- a/permission_handler/README.md +++ b/permission_handler/README.md @@ -33,7 +33,7 @@ android.useAndroidX=true android.enableJetifier=true ``` -2. Make sure you set the `compileSdkVersion` in your "android/app/build.gradle" file to 33: +2. Make sure you set the `compileSdkVersion` in your "android/app/build.gradle" file to 35: ```gradle android { @@ -52,18 +52,132 @@ In general, it's sufficient to add permission only to the `main` version.
-iOS (click to expand) +iOS - Swift Package Manager (click to expand) + +> Requires Flutter 3.24.0 or higher and Xcode 15.0 or higher. + +With SPM, `Package.swift` automatically detects which permissions to enable by reading your app's `Info.plist`. A permission is compiled in when its corresponding usage description key is present: + +| Permission group | Info.plist key | +|---|---| +| `PermissionGroup.calendar` (< iOS 17) | `NSCalendarsUsageDescription` | +| `PermissionGroup.calendarWriteOnly` / `calendarFullAccess` (iOS 17+) | `NSCalendarsFullAccessUsageDescription` or `NSCalendarsWriteOnlyAccessUsageDescription` | +| `PermissionGroup.reminders` | `NSRemindersUsageDescription` | +| `PermissionGroup.contacts` | `NSContactsUsageDescription` | +| `PermissionGroup.camera` | `NSCameraUsageDescription` | +| `PermissionGroup.microphone` | `NSMicrophoneUsageDescription` | +| `PermissionGroup.speech` | `NSSpeechRecognitionUsageDescription` | +| `PermissionGroup.photos` | `NSPhotoLibraryUsageDescription` or `NSPhotoLibraryAddUsageDescription` | +| `PermissionGroup.photosAddOnly` | `NSPhotoLibraryAddUsageDescription` | +| `PermissionGroup.location` | `NSLocationWhenInUseUsageDescription` or `NSLocationAlwaysAndWhenInUseUsageDescription` | +| `PermissionGroup.locationWhenInUse` | `NSLocationWhenInUseUsageDescription` | +| `PermissionGroup.locationAlways` | `NSLocationAlwaysAndWhenInUseUsageDescription` | +| `PermissionGroup.mediaLibrary` | `NSAppleMusicUsageDescription` | +| `PermissionGroup.sensors` | `NSMotionUsageDescription` | +| `PermissionGroup.bluetooth` | `NSBluetoothAlwaysUsageDescription` or `NSBluetoothPeripheralUsageDescription` | +| `PermissionGroup.appTrackingTransparency` | `NSUserTrackingUsageDescription` | +| `PermissionGroup.assistant` | `NSSiriUsageDescription` | +| `PermissionGroup.notification` | *(enabled by default — see below)* | +| `PermissionGroup.criticalAlerts` | *(disabled by default — see below)* | + +Because you must already add these keys to `Info.plist` for any permission to work, a single-flavor app needs no additional configuration. + +`Info.plist` files are located through the `INFOPLIST_FILE` setting of your Xcode project and `.xcconfig` files, so build-configuration specific plists (`Info-Debug.plist`, `Runner/Info-$(CONFIGURATION).plist`, …) are picked up as well. When an app has several of them, the keys are **merged**: a permission declared in any one of them is compiled into every build configuration. + +#### Special cases: permissions without an Info.plist key + +**`PermissionGroup.notification`** has no required `Info.plist` key and is **enabled by default**. To opt out, disable it via environment variable before building: + +```bash +# When building from terminal (flutter run / flutter build) +export PERMISSION_NOTIFICATIONS=0 + +# When building from Xcode GUI (set once per Mac session, then restart Xcode) +launchctl setenv PERMISSION_NOTIFICATIONS 0 +``` + +**`PermissionGroup.criticalAlerts`** requires a [special entitlement](https://developer.apple.com/documentation/usernotifications/asking-permission-to-use-notifications) granted by Apple and is **disabled by default** to avoid compiling unused code into apps that don't need it. Enable it explicitly: + +```bash +# When building from terminal +export PERMISSION_CRITICAL_ALERTS=1 + +# When building from Xcode GUI +launchctl setenv PERMISSION_CRITICAL_ALERTS 1 +``` + +**After changing any env var or Info.plist key**, clear Xcode's package cache once so `Package.swift` is re-evaluated: + +```bash +rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +Then run `flutter build ios` or rebuild in Xcode as usual. + +#### Apps with flavors + +Merging is wrong when your flavors need *different* permissions: a permission declared only in `Info-dev.plist` is compiled into your production binary too, which is grounds for App Store rejection (`ITMS-90683`). Declare a `permission_handler.yaml` next to your `pubspec.yaml` to give each flavor its own `Info.plist`, and select the flavor before building: + +```yaml +strict: true +flavors: + dev: + info-plist: ios/Runner/Info-dev.plist + configurations: [Debug-dev, Profile-dev, Release-dev] + prod: + info-plist: ios/Runner/Info-prod.plist + configurations: [Debug-prod, Profile-prod, Release-prod] +``` + +```bash +dart run permission_handler_apple:select prod +flutter run --flavor prod +``` + +Only the selected flavor's `Info.plist` is read and nothing is merged, so a flavor can never inherit another flavor's permissions. See [Per-flavor permissions](https://github.com/Baseflow/flutter-permission-handler/blob/main/permission_handler_apple/README.md#per-flavor-permissions) for the build phase that catches a stale selection. + +#### Builds started from Xcode.app + +Automatic detection finds your app through the build's working directory, which points at the Flutter project for `flutter run`, `flutter build ios` and `xcodebuild`. Builds started from Xcode.app run with `/` as their working directory and cannot be detected, so point the manifest at your `Info.plist` explicitly: + +```bash +launchctl setenv PERMISSION_HANDLER_INFO_PLIST /absolute/path/to/ios/Runner/Info.plist +rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +#### Troubleshooting + +If no `Info.plist` is found, **every permission is compiled out and all permission checks report `denied`**. Xcode discards Swift package manifest output, so the warning about this only reaches you from the command line: + +```bash +cd your_app +PERMISSION_HANDLER_VERBOSE=1 swift package --manifest-cache none \ + --package-path ios/Flutter/ephemeral/Packages/.packages/permission_handler_apple \ + dump-package > /dev/null +``` + +That prints the `Info.plist` files that were used, the active flavor if you declared one, and the value every `PERMISSION_*` macro resolved to. + +#### More detail + +The [`permission_handler_apple` README](https://github.com/Baseflow/flutter-permission-handler/blob/main/permission_handler_apple/README.md#swift-package-manager) documents this in full, including every `PERMISSION_HANDLER_*` environment variable and the per-flavor workflow. + +
+ +
+iOS - CocoaPods (click to expand) Add permission to your `Info.plist` file. [Here](https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler/example/ios/Runner/Info.plist)'s an example `Info.plist` with a complete list of all possible permissions. -> IMPORTANT: ~~You will have to include all permission options when you want to submit your App.~~ This is because the `permission_handler` plugin touches all different SDKs and because the static code analyzer (run by Apple upon App submission) detects this and will assert if it cannot find a matching permission option in the `Info.plist`. More information about this can be found [here](https://github.com/Baseflow/flutter-permission-handler/issues/26). +> IMPORTANT: ~~You will have to include all permission options when you want to submit your App. This is because the `permission_handler` plugin touches all different SDKs and because the static code analyzer (run by Apple upon App submission) detects this and will assert if it cannot find a matching permission option in the `Info.plist`. More information about this can be found [here](https://github.com/Baseflow/flutter-permission-handler/issues/26).~~ + This has been fixed since version 8.0.0, now permission_handler by default excludes all permissions and developers only have to enable those that the app really needs. The permission_handler plugin use [macros](https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h) to control whether a permission is enabled. You must list the permission you want to use in your application: -1. Add the following to your `Podfile` file: +1. Add the following to your `Podfile`'s `post_install` block: ```ruby post_install do |installer| @@ -73,7 +187,8 @@ You must list the permission you want to use in your application: target.build_configurations.each do |config| # You can remove unused permissions here # for more information: https://github.com/Baseflow/flutter-permission-handler/blob/main/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h - # e.g. when you don't need camera permission, just add 'PERMISSION_CAMERA=0' + # When you don't need a permission, just change its value to 0 + # e.g. 'PERMISSION_CAMERA=0' instead of 'PERMISSION_CAMERA=1' config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', @@ -136,16 +251,27 @@ You must list the permission you want to use in your application: end ``` -2. Remove the `#` character in front of the permission you want to use. For example, if you need access to the calendar make sure the code looks like this: +2. For the permissions you *want* to use, keep them as is. For example, if you need access to the calendar make sure the code looks like this: ```ruby ## dart: PermissionGroup.calendar 'PERMISSION_EVENTS=1', ``` -3. Delete the corresponding permission description in `Info.plist` - e.g. when you don't need camera permission, just delete 'NSCameraUsageDescription' - The following lists the relationship between `Permission` and `The key of Info.plist`: + Also keep that permission's usage description in `Info.plist` (for calendar: `NSCalendarsUsageDescription`). The example plist above is a complete list so you can copy the keys you actually use. + +3. When you **don't** need a permission, set its macro to `0` **and** delete that same permission's usage description from `Info.plist`. The key to delete is the one for the permission you just disabled, not the calendar example in step 2. + + For example, if you don't need calendar access: + + ```ruby + ## dart: PermissionGroup.calendar + 'PERMISSION_EVENTS=0', + ``` + + Then delete `NSCalendarsUsageDescription` from `Info.plist`. + +The following lists the relationship between `Permission` and `The key of Info.plist`: | Permission | Info.plist | Macro | |---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------| @@ -182,7 +308,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. @@ -244,14 +372,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 @@ -280,6 +413,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). @@ -294,7 +431,7 @@ Starting with Android 10, apps are required to first obtain permission to read t ### onRequestPermissionsResult is called without results. What can I do? -It is probably caused by a difference between completeSdkVersion and targetSdkVersion. It can be depending on the flutter version that you use. `targetSdkVersion = flutter.targetSdkVersion` in the app/build.gradle indicates that the targetSdkVersion is flutter version dependant. For more information: [issue 1222](https://github.com/Baseflow/flutter-permission-handler/issues/1222) +It is probably caused by a difference between compileSdkVersion and targetSdkVersion. It can be depending on the flutter version that you use. `targetSdkVersion = flutter.targetSdkVersion` in the app/build.gradle indicates that the targetSdkVersion is flutter version dependant. For more information: [issue 1222](https://github.com/Baseflow/flutter-permission-handler/issues/1222) ### Checking or requesting a permission terminates the application on iOS. What can I do? diff --git a/permission_handler/example/android/app/build.gradle b/permission_handler/example/android/app/build.gradle deleted file mode 100644 index 537b42534..000000000 --- a/permission_handler/example/android/app/build.gradle +++ /dev/null @@ -1,55 +0,0 @@ -plugins { - id "com.android.application" - id "dev.flutter.flutter-gradle-plugin" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -android { - if (project.android.hasProperty("namespace")) { - namespace 'com.baseflow.permissionhandlerexample' - } - compileSdkVersion 35 - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.baseflow.permissionhandler.example" - minSdkVersion flutter.minSdkVersion - targetSdkVersion 34 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} diff --git a/permission_handler/example/android/app/build.gradle.kts b/permission_handler/example/android/app/build.gradle.kts new file mode 100644 index 000000000..602003d43 --- /dev/null +++ b/permission_handler/example/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.baseflow.permissionhandler.example" + compileSdk = 37 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.baseflow.permissionhandler.example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = 35 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/permission_handler/example/android/build.gradle b/permission_handler/example/android/build.gradle deleted file mode 100644 index 8f31e8caf..000000000 --- a/permission_handler/example/android/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} \ No newline at end of file diff --git a/permission_handler/example/android/build.gradle.kts b/permission_handler/example/android/build.gradle.kts new file mode 100644 index 000000000..dbee657bb --- /dev/null +++ b/permission_handler/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/permission_handler/example/android/gradle.properties b/permission_handler/example/android/gradle.properties index 94adc3a3f..d5da7278a 100644 --- a/permission_handler/example/android/gradle.properties +++ b/permission_handler/example/android/gradle.properties @@ -1,3 +1,6 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/permission_handler/example/android/gradle/wrapper/gradle-wrapper.properties b/permission_handler/example/android/gradle/wrapper/gradle-wrapper.properties index db18181ac..a97e89ca1 100644 --- a/permission_handler/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/permission_handler/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/permission_handler/example/android/settings.gradle b/permission_handler/example/android/settings.gradle deleted file mode 100644 index 56eb85cb1..000000000 --- a/permission_handler/example/android/settings.gradle +++ /dev/null @@ -1,24 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.0" apply false -} - -include ":app" \ No newline at end of file diff --git a/permission_handler/example/android/settings.gradle.kts b/permission_handler/example/android/settings.gradle.kts new file mode 100644 index 000000000..c21f0c5b4 --- /dev/null +++ b/permission_handler/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/permission_handler/example/ios/.gitignore b/permission_handler/example/ios/.gitignore index e96ef602b..8a62ea716 100644 --- a/permission_handler/example/ios/.gitignore +++ b/permission_handler/example/ios/.gitignore @@ -1,3 +1,9 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +**/dgph *.mode1v3 *.mode2v3 *.moved-aside @@ -18,6 +24,7 @@ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig +Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ @@ -30,3 +37,6 @@ Runner/GeneratedPluginRegistrant.* !default.mode2v3 !default.pbxuser !default.perspectivev3 +# Swift Package Manager +.build/ +*.resolved diff --git a/permission_handler/example/lib/main.dart b/permission_handler/example/lib/main.dart index e37e51302..9dbe35a1d 100644 --- a/permission_handler/example/lib/main.dart +++ b/permission_handler/example/lib/main.dart @@ -18,8 +18,8 @@ void main() { ///Defines the main theme color final MaterialColor themeMaterialColor = BaseflowPluginExample.createMaterialColor( - const Color.fromRGBO(48, 49, 60, 1), - ); + const Color.fromRGBO(48, 49, 60, 1), +); /// A Flutter application demonstrating the functionality of this plugin class PermissionHandlerWidget extends StatefulWidget { @@ -43,41 +43,40 @@ class _PermissionHandlerWidgetState extends State { Widget build(BuildContext context) { return Center( child: ListView( - children: - Permission.values - .where((permission) { - if (Platform.isIOS) { - return permission != Permission.unknown && - permission != Permission.phone && - permission != Permission.sms && - permission != Permission.ignoreBatteryOptimizations && - permission != Permission.accessMediaLocation && - permission != Permission.activityRecognition && - permission != Permission.manageExternalStorage && - permission != Permission.systemAlertWindow && - permission != Permission.requestInstallPackages && - permission != Permission.accessNotificationPolicy && - permission != Permission.bluetoothScan && - permission != Permission.bluetoothAdvertise && - permission != Permission.bluetoothConnect && - permission != Permission.nearbyWifiDevices && - permission != Permission.videos && - permission != Permission.audio && - permission != Permission.scheduleExactAlarm && - permission != Permission.sensorsAlways; - } else { - return permission != Permission.unknown && - permission != Permission.mediaLibrary && - permission != Permission.photosAddOnly && - permission != Permission.reminders && - permission != Permission.bluetooth && - permission != Permission.appTrackingTransparency && - permission != Permission.criticalAlerts && - permission != Permission.assistant; - } - }) - .map((permission) => PermissionWidget(permission)) - .toList(), + children: Permission.values + .where((permission) { + if (Platform.isIOS) { + return permission != Permission.unknown && + permission != Permission.phone && + permission != Permission.sms && + permission != Permission.ignoreBatteryOptimizations && + permission != Permission.accessMediaLocation && + permission != Permission.activityRecognition && + permission != Permission.manageExternalStorage && + permission != Permission.systemAlertWindow && + permission != Permission.requestInstallPackages && + permission != Permission.accessNotificationPolicy && + permission != Permission.bluetoothScan && + permission != Permission.bluetoothAdvertise && + permission != Permission.bluetoothConnect && + permission != Permission.nearbyWifiDevices && + permission != Permission.videos && + permission != Permission.audio && + permission != Permission.scheduleExactAlarm && + permission != Permission.sensorsAlways; + } else { + return permission != Permission.unknown && + permission != Permission.mediaLibrary && + permission != Permission.photosAddOnly && + permission != Permission.reminders && + permission != Permission.bluetooth && + permission != Permission.appTrackingTransparency && + permission != Permission.criticalAlerts && + permission != Permission.assistant; + } + }) + .map((permission) => PermissionWidget(permission)) + .toList(), ), ); } @@ -136,18 +135,17 @@ class _PermissionState extends State { _permissionStatus.toString(), style: TextStyle(color: getPermissionColor()), ), - trailing: - (widget.permission is PermissionWithService) - ? IconButton( - icon: const Icon(Icons.info, color: Colors.white), - onPressed: () { - checkServiceStatus( - context, - widget.permission as PermissionWithService, - ); - }, - ) - : null, + trailing: (widget.permission is PermissionWithService) + ? IconButton( + icon: const Icon(Icons.info, color: Colors.white), + onPressed: () { + checkServiceStatus( + context, + widget.permission as PermissionWithService, + ); + }, + ) + : null, onTap: () { requestPermission(widget.permission); }, diff --git a/permission_handler/example/pubspec.yaml b/permission_handler/example/pubspec.yaml index 8ea895146..4b6545123 100644 --- a/permission_handler/example/pubspec.yaml +++ b/permission_handler/example/pubspec.yaml @@ -2,7 +2,9 @@ name: permission_handler_example description: Demonstrates how to use the permission_handler plugin. environment: - sdk: ^3.7.0 + sdk: ^3.6.0 + flutter: ">=3.24.0" +resolution: workspace dependencies: baseflow_plugin_template: ^2.1.1 @@ -20,6 +22,7 @@ dev_dependencies: flutter_test: sdk: flutter url_launcher: ^6.3.1 + flutter_lints: ^5.0.0 flutter: uses-material-design: true diff --git a/permission_handler/pubspec.yaml b/permission_handler/pubspec.yaml index 3e0861cd9..4811c78e7 100644 --- a/permission_handler/pubspec.yaml +++ b/permission_handler/pubspec.yaml @@ -2,11 +2,12 @@ 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: 12.0.1 +version: 13.0.2 environment: - sdk: ^3.5.0 + sdk: ^3.6.0 flutter: ">=3.24.0" +resolution: workspace flutter: plugin: @@ -24,11 +25,11 @@ dependencies: flutter: sdk: flutter meta: ^1.7.0 - permission_handler_android: ^13.0.0 - permission_handler_apple: ^9.4.6 - permission_handler_html: ^0.1.1 - permission_handler_windows: ^0.2.1 - permission_handler_platform_interface: ^4.3.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 + permission_handler_platform_interface: ^4.4.0 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/permission_handler/test/permission_handler_test.dart b/permission_handler/test/permission_handler_test.dart index ae666e9a2..66ddfae12 100644 --- a/permission_handler/test/permission_handler_test.dart +++ b/permission_handler/test/permission_handler_test.dart @@ -23,21 +23,27 @@ void main() { }); test( - // ignore: lines_longer_than_80_chars - 'PermissionActions on Permission: get shouldShowRequestRationale should return true when on android', - () async { - final mockPermissionHandlerPlatform = PermissionHandlerPlatform.instance; - - when(mockPermissionHandlerPlatform - .shouldShowRequestPermissionRationale(Permission.contacts)) - .thenAnswer((_) => Future.value(true)); - - await Permission.contacts.shouldShowRequestRationale; - - verify(mockPermissionHandlerPlatform - .shouldShowRequestPermissionRationale(Permission.contacts)) - .called(1); - }); + // ignore: lines_longer_than_80_chars + 'PermissionActions on Permission: get shouldShowRequestRationale should return true when on android', + () async { + final mockPermissionHandlerPlatform = + PermissionHandlerPlatform.instance; + + when( + mockPermissionHandlerPlatform.shouldShowRequestPermissionRationale( + Permission.contacts, + ), + ).thenAnswer((_) => Future.value(true)); + + await Permission.contacts.shouldShowRequestRationale; + + verify( + mockPermissionHandlerPlatform.shouldShowRequestPermissionRationale( + Permission.contacts, + ), + ).called(1); + }, + ); test('PermissionActions on Permission: request()', () async { final permissionRequest = Permission.contacts.request(); @@ -65,11 +71,14 @@ void main() { expect(isLimited, false); }); - test('PermissionCheckShortcuts on Permission: get isPermanentlyDenied', - () async { - final isPermanentlyDenied = await Permission.contacts.isPermanentlyDenied; - expect(isPermanentlyDenied, false); - }); + test( + 'PermissionCheckShortcuts on Permission: get isPermanentlyDenied', + () async { + final isPermanentlyDenied = + await Permission.contacts.isPermanentlyDenied; + expect(isPermanentlyDenied, false); + }, + ); test('PermissionCheckShortcuts on Permission: get isProvisional', () async { final isProvisional = await Permission.contacts.isProvisional; @@ -77,23 +86,25 @@ void main() { }); test( - // ignore: lines_longer_than_80_chars - 'ServicePermissionActions on PermissionWithService: get ServiceStatus returns the right service status', - () async { - var serviceStatus = await Permission.phone.serviceStatus; + // ignore: lines_longer_than_80_chars + 'ServicePermissionActions on PermissionWithService: get ServiceStatus returns the right service status', + () async { + var serviceStatus = await Permission.phone.serviceStatus; - expect(serviceStatus, ServiceStatus.enabled); - }); + expect(serviceStatus, ServiceStatus.enabled); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'PermissionListActions on List: request() on a list returns a Map', - () async { - var permissionList = []; - final permissionMap = await permissionList.request(); - - expect(permissionMap, isA>()); - }); + // ignore: lines_longer_than_80_chars + 'PermissionListActions on List: request() on a list returns a Map', + () async { + var permissionList = []; + final permissionMap = await permissionList.request(); + + expect(permissionMap, isA>()); + }, + ); test('onDeniedCallback sets onDenied', () async { bool callbackCalled = false; @@ -151,7 +162,8 @@ void main() { .onDeniedCallback(() => callbackCalled.add('Denied')) .onGrantedCallback(() => callbackCalled.add('Granted')) .onPermanentlyDeniedCallback( - () => callbackCalled.add('PermanentlyDenied')) + () => callbackCalled.add('PermanentlyDenied'), + ) .onRestrictedCallback(() => callbackCalled.add('Restricted')) .onLimitedCallback(() => callbackCalled.add('Limited')) .onProvisionalCallback(() => callbackCalled.add('Provisional')) @@ -181,7 +193,8 @@ class MockPermissionHandlerPlatform extends Mock @override Future> requestPermissions( - List permissions) { + List permissions, + ) { var permissionsMap = {}; return Future.value(permissionsMap); } @@ -189,10 +202,7 @@ class MockPermissionHandlerPlatform extends Mock @override Future shouldShowRequestPermissionRationale(Permission? permission) { return super.noSuchMethod( - Invocation.method( - #shouldShowPermissionRationale, - [permission], - ), + Invocation.method(#shouldShowPermissionRationale, [permission]), returnValue: Future.value(true), ); } diff --git a/permission_handler_android/CHANGELOG.md b/permission_handler_android/CHANGELOG.md index dfec82c83..a017182ba 100644 --- a/permission_handler_android/CHANGELOG.md +++ b/permission_handler_android/CHANGELOG.md @@ -1,7 +1,23 @@ +## 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`. +- Updates Android `compileSdkVersion: 35` to `37` +- Bump compileSDK version to 37 + +## 13.0.2 + +- Added support for the new Android 17 permission `ACCESS_LOCAL_NETWORK` ## 13.0.1 -* fix: Resolve `PermissionRequestInProgressException` when app is relaunched with non-standard launchMode. +- fix: Resolve `PermissionRequestInProgressException` when app is relaunched with non-standard launchMode. ## 13.0.0 diff --git a/permission_handler_android/android/build.gradle b/permission_handler_android/android/build.gradle deleted file mode 100644 index fff4a308b..000000000 --- a/permission_handler_android/android/build.gradle +++ /dev/null @@ -1,39 +0,0 @@ -group 'com.baseflow.permissionhandler' -version '1.0' - -buildscript { - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:8.0.2' - } -} - -rootProject.allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: 'com.android.library' - -android { - // Conditional for compatibility with AGP <4.2. - if (project.android.hasProperty("namespace")) { - namespace 'com.baseflow.permissionhandler' - } - compileSdkVersion 35 - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - defaultConfig { - minSdkVersion 19 - } -} diff --git a/permission_handler_android/android/build.gradle.kts b/permission_handler_android/android/build.gradle.kts new file mode 100644 index 000000000..da4d8033d --- /dev/null +++ b/permission_handler_android/android/build.gradle.kts @@ -0,0 +1,77 @@ +group = "com.baseflow.permissionhandler" +version = "1.0" + +buildscript { + val kotlinVersion = "2.3.20" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.0.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "com.baseflow.permissionhandler" + + compileSdk = 37 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/permission_handler_android/android/gradle/wrapper/gradle-wrapper.properties b/permission_handler_android/android/gradle/wrapper/gradle-wrapper.properties index da9702f9e..d706aba60 100644 --- a/permission_handler_android/android/gradle/wrapper/gradle-wrapper.properties +++ b/permission_handler_android/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/permission_handler_android/android/settings.gradle b/permission_handler_android/android/settings.gradle.kts similarity index 100% rename from permission_handler_android/android/settings.gradle rename to permission_handler_android/android/settings.gradle.kts diff --git a/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionConstants.java b/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionConstants.java index 0262db176..734aa019f 100644 --- a/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionConstants.java +++ b/permission_handler_android/android/src/main/java/com/baseflow/permissionhandler/PermissionConstants.java @@ -1,13 +1,13 @@ package com.baseflow.permissionhandler; import androidx.annotation.IntDef; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; final class PermissionConstants { + static final String LOG_TAG = "permissions_handler"; static final int PERMISSION_CODE = 24; static final int PERMISSION_CODE_IGNORE_BATTERY_OPTIMIZATIONS = 209; @@ -17,7 +17,6 @@ final class PermissionConstants { static final int PERMISSION_CODE_ACCESS_NOTIFICATION_POLICY = 213; static final int PERMISSION_CODE_SCHEDULE_EXACT_ALARM = 214; - // PERMISSION_GROUP // Deprecated in favor of PERMISSION_GROUP_CALENDAR_WRITE_ONLY and @@ -62,48 +61,50 @@ final class PermissionConstants { static final int PERMISSION_GROUP_CALENDAR_FULL_ACCESS = 37; static final int PERMISSION_GROUP_ASSISTANT = 38; static final int PERMISSION_GROUP_BACKGROUND_REFRESH = 39; + static final int PERMISSION_GROUP_ACCESS_LOCAL_NETWORK = 40; @Retention(RetentionPolicy.SOURCE) @IntDef({ - PERMISSION_GROUP_CALENDAR, - PERMISSION_GROUP_CAMERA, - PERMISSION_GROUP_CONTACTS, - PERMISSION_GROUP_LOCATION, - PERMISSION_GROUP_LOCATION_ALWAYS, - PERMISSION_GROUP_LOCATION_WHEN_IN_USE, - PERMISSION_GROUP_MEDIA_LIBRARY, - PERMISSION_GROUP_MICROPHONE, - PERMISSION_GROUP_PHONE, - PERMISSION_GROUP_PHOTOS, - PERMISSION_GROUP_REMINDERS, - PERMISSION_GROUP_SENSORS, - PERMISSION_GROUP_SENSORS_ALWAYS, - PERMISSION_GROUP_SMS, - PERMISSION_GROUP_SPEECH, - PERMISSION_GROUP_STORAGE, - PERMISSION_GROUP_IGNORE_BATTERY_OPTIMIZATIONS, - PERMISSION_GROUP_NOTIFICATION, - PERMISSION_GROUP_ACCESS_MEDIA_LOCATION, - PERMISSION_GROUP_ACTIVITY_RECOGNITION, - PERMISSION_GROUP_UNKNOWN, - PERMISSION_GROUP_BLUETOOTH, - PERMISSION_GROUP_MANAGE_EXTERNAL_STORAGE, - PERMISSION_GROUP_SYSTEM_ALERT_WINDOW, - PERMISSION_GROUP_REQUEST_INSTALL_PACKAGES, - PERMISSION_GROUP_ACCESS_NOTIFICATION_POLICY, - PERMISSION_GROUP_BLUETOOTH_SCAN, - PERMISSION_GROUP_BLUETOOTH_ADVERTISE, - PERMISSION_GROUP_BLUETOOTH_CONNECT, - PERMISSION_GROUP_NEARBY_WIFI_DEVICES, - PERMISSION_GROUP_VIDEOS, - PERMISSION_GROUP_AUDIO, - PERMISSION_GROUP_SCHEDULE_EXACT_ALARM, - PERMISSION_GROUP_CALENDAR_WRITE_ONLY, - PERMISSION_GROUP_CALENDAR_FULL_ACCESS, - PERMISSION_GROUP_ASSISTANT, + PERMISSION_GROUP_CALENDAR, + PERMISSION_GROUP_CAMERA, + PERMISSION_GROUP_CONTACTS, + PERMISSION_GROUP_LOCATION, + PERMISSION_GROUP_LOCATION_ALWAYS, + PERMISSION_GROUP_LOCATION_WHEN_IN_USE, + PERMISSION_GROUP_MEDIA_LIBRARY, + PERMISSION_GROUP_MICROPHONE, + PERMISSION_GROUP_PHONE, + PERMISSION_GROUP_PHOTOS, + PERMISSION_GROUP_REMINDERS, + PERMISSION_GROUP_SENSORS, + PERMISSION_GROUP_SENSORS_ALWAYS, + PERMISSION_GROUP_SMS, + PERMISSION_GROUP_SPEECH, + PERMISSION_GROUP_STORAGE, + PERMISSION_GROUP_IGNORE_BATTERY_OPTIMIZATIONS, + PERMISSION_GROUP_NOTIFICATION, + PERMISSION_GROUP_ACCESS_MEDIA_LOCATION, + PERMISSION_GROUP_ACTIVITY_RECOGNITION, + PERMISSION_GROUP_UNKNOWN, + PERMISSION_GROUP_BLUETOOTH, + PERMISSION_GROUP_MANAGE_EXTERNAL_STORAGE, + PERMISSION_GROUP_SYSTEM_ALERT_WINDOW, + PERMISSION_GROUP_REQUEST_INSTALL_PACKAGES, + PERMISSION_GROUP_ACCESS_NOTIFICATION_POLICY, + PERMISSION_GROUP_BLUETOOTH_SCAN, + PERMISSION_GROUP_BLUETOOTH_ADVERTISE, + PERMISSION_GROUP_BLUETOOTH_CONNECT, + PERMISSION_GROUP_NEARBY_WIFI_DEVICES, + PERMISSION_GROUP_VIDEOS, + PERMISSION_GROUP_AUDIO, + PERMISSION_GROUP_SCHEDULE_EXACT_ALARM, + PERMISSION_GROUP_CALENDAR_WRITE_ONLY, + PERMISSION_GROUP_CALENDAR_FULL_ACCESS, + PERMISSION_GROUP_ASSISTANT, + PERMISSION_GROUP_BACKGROUND_REFRESH, + PERMISSION_GROUP_ACCESS_LOCAL_NETWORK, }) - @interface PermissionGroup { - } + @interface PermissionGroup {} //PERMISSION_STATUS static final int PERMISSION_STATUS_DENIED = 0; @@ -115,14 +116,13 @@ final class PermissionConstants { @Target(ElementType.TYPE_USE) @Retention(RetentionPolicy.SOURCE) @IntDef({ - PERMISSION_STATUS_DENIED, - PERMISSION_STATUS_GRANTED, - PERMISSION_STATUS_RESTRICTED, - PERMISSION_STATUS_LIMITED, - PERMISSION_STATUS_NEVER_ASK_AGAIN + PERMISSION_STATUS_DENIED, + PERMISSION_STATUS_GRANTED, + PERMISSION_STATUS_RESTRICTED, + PERMISSION_STATUS_LIMITED, + PERMISSION_STATUS_NEVER_ASK_AGAIN, }) - @interface PermissionStatus { - } + @interface PermissionStatus {} //SERVICE_STATUS static final int SERVICE_STATUS_DISABLED = 0; @@ -131,10 +131,9 @@ final class PermissionConstants { @Retention(RetentionPolicy.SOURCE) @IntDef({ - SERVICE_STATUS_DISABLED, - SERVICE_STATUS_ENABLED, - SERVICE_STATUS_NOT_APPLICABLE + SERVICE_STATUS_DISABLED, + SERVICE_STATUS_ENABLED, + SERVICE_STATUS_NOT_APPLICABLE, }) - @interface ServiceStatus { - } -} \ No newline at end of file + @interface ServiceStatus {} +} 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..462d4aaf2 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,56 +231,65 @@ 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)) { + if (!requestResults.containsKey(PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS) || + requestResults.get(PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS) != PermissionConstants.PERMISSION_STATUS_GRANTED) { requestResults.put( PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS, permissionStatus); } } - if (!requestResults.containsKey(PermissionConstants.PERMISSION_GROUP_LOCATION_WHEN_IN_USE)) { + if (!requestResults.containsKey(PermissionConstants.PERMISSION_GROUP_LOCATION_WHEN_IN_USE) || + requestResults.get(PermissionConstants.PERMISSION_GROUP_LOCATION_WHEN_IN_USE) != PermissionConstants.PERMISSION_STATUS_GRANTED) { requestResults.put( PermissionConstants.PERMISSION_GROUP_LOCATION_WHEN_IN_USE, permissionStatus); } - requestResults.put(permission, permissionStatus); + if (!requestResults.containsKey(permission) || + requestResults.get(permission) != PermissionConstants.PERMISSION_STATUS_GRANTED) { + requestResults.put(permission, permissionStatus); + } // [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 +305,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 +373,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 +455,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 +474,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) { @@ -502,6 +553,19 @@ private int determinePermissionStatus(final @PermissionConstants.PermissionGroup : PermissionConstants.PERMISSION_STATUS_DENIED; } + if (context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.M) { + if (permission == PermissionConstants.PERMISSION_GROUP_LOCATION || + permission == PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS || + permission == PermissionConstants.PERMISSION_GROUP_LOCATION_WHEN_IN_USE) { + boolean isCoarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED; + boolean isFineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; + + if (isCoarseGranted || isFineGranted) { + return PermissionConstants.PERMISSION_STATUS_GRANTED; + } + } + } + final boolean requiresExplicitPermission = context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.M; if (requiresExplicitPermission) { @@ -562,14 +626,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 +718,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 c56bcfdfb..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 @@ -10,12 +10,9 @@ import android.os.Build; import android.os.Environment; 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; import java.util.Collection; @@ -23,7 +20,9 @@ import java.util.List; public class PermissionUtils { - final static String SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY = "sp_permission_handler_permission_was_denied_before"; + + static final String SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY = + "sp_permission_handler_permission_was_denied_before"; @PermissionConstants.PermissionGroup static int parseManifestName(String permission) { @@ -95,45 +94,81 @@ static int parseManifestName(String permission) { return PermissionConstants.PERMISSION_GROUP_AUDIO; case Manifest.permission.SCHEDULE_EXACT_ALARM: return PermissionConstants.PERMISSION_GROUP_SCHEDULE_EXACT_ALARM; + case Manifest.permission.ACCESS_LOCAL_NETWORK: + return PermissionConstants.PERMISSION_GROUP_ACCESS_LOCAL_NETWORK; default: return PermissionConstants.PERMISSION_GROUP_UNKNOWN; } } @TargetApi(22) - static List getManifestNames(Context context, @PermissionConstants.PermissionGroup int permission) { + static List getManifestNames( + Context context, + @PermissionConstants.PermissionGroup int permission + ) { final ArrayList permissionNames = new ArrayList<>(); switch (permission) { case PermissionConstants.PERMISSION_GROUP_CALENDAR_WRITE_ONLY: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.WRITE_CALENDAR)) - permissionNames.add(Manifest.permission.WRITE_CALENDAR); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.WRITE_CALENDAR + ) + ) permissionNames.add(Manifest.permission.WRITE_CALENDAR); break; - case PermissionConstants.PERMISSION_GROUP_CALENDAR_FULL_ACCESS: case PermissionConstants.PERMISSION_GROUP_CALENDAR: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.WRITE_CALENDAR)) - permissionNames.add(Manifest.permission.WRITE_CALENDAR); - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_CALENDAR)) - permissionNames.add(Manifest.permission.READ_CALENDAR); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.WRITE_CALENDAR + ) + ) permissionNames.add(Manifest.permission.WRITE_CALENDAR); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_CALENDAR + ) + ) permissionNames.add(Manifest.permission.READ_CALENDAR); break; - case PermissionConstants.PERMISSION_GROUP_CAMERA: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.CAMERA)) - permissionNames.add(Manifest.permission.CAMERA); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.CAMERA + ) + ) permissionNames.add(Manifest.permission.CAMERA); break; - case PermissionConstants.PERMISSION_GROUP_CONTACTS: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_CONTACTS)) - permissionNames.add(Manifest.permission.READ_CONTACTS); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.WRITE_CONTACTS)) - permissionNames.add(Manifest.permission.WRITE_CONTACTS); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.GET_ACCOUNTS)) - permissionNames.add(Manifest.permission.GET_ACCOUNTS); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_CONTACTS + ) + ) permissionNames.add(Manifest.permission.READ_CONTACTS); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.WRITE_CONTACTS + ) + ) permissionNames.add(Manifest.permission.WRITE_CONTACTS); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.GET_ACCOUNTS + ) + ) permissionNames.add(Manifest.permission.GET_ACCOUNTS); break; - case PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS: case PermissionConstants.PERMISSION_GROUP_LOCATION_WHEN_IN_USE: case PermissionConstants.PERMISSION_GROUP_LOCATION: @@ -141,150 +176,324 @@ static List getManifestNames(Context context, @PermissionConstants.Permi // case on pre Android Q devices. The ACCESS_BACKGROUND_LOCATION permission was only // introduced in Android Q, before it should be treated as the ACCESS_COARSE_LOCATION or // ACCESS_FINE_LOCATION. - if (permission == PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.ACCESS_BACKGROUND_LOCATION)) - permissionNames.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION); + if ( + permission == + PermissionConstants.PERMISSION_GROUP_LOCATION_ALWAYS && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ) { + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) + ) permissionNames.add( + Manifest.permission.ACCESS_BACKGROUND_LOCATION + ); break; } - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.ACCESS_COARSE_LOCATION)) - permissionNames.add(Manifest.permission.ACCESS_COARSE_LOCATION); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.ACCESS_FINE_LOCATION)) - permissionNames.add(Manifest.permission.ACCESS_FINE_LOCATION); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) permissionNames.add( + Manifest.permission.ACCESS_COARSE_LOCATION + ); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ACCESS_FINE_LOCATION + ) + ) permissionNames.add(Manifest.permission.ACCESS_FINE_LOCATION); break; case PermissionConstants.PERMISSION_GROUP_SPEECH: case PermissionConstants.PERMISSION_GROUP_MICROPHONE: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.RECORD_AUDIO)) - permissionNames.add(Manifest.permission.RECORD_AUDIO); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.RECORD_AUDIO + ) + ) permissionNames.add(Manifest.permission.RECORD_AUDIO); break; - case PermissionConstants.PERMISSION_GROUP_PHONE: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_PHONE_STATE)) - permissionNames.add(Manifest.permission.READ_PHONE_STATE); - - if (android.os.Build.VERSION.SDK_INT > Build.VERSION_CODES.Q && hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_PHONE_NUMBERS)) - permissionNames.add(Manifest.permission.READ_PHONE_NUMBERS); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.CALL_PHONE)) - permissionNames.add(Manifest.permission.CALL_PHONE); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_CALL_LOG)) - permissionNames.add(Manifest.permission.READ_CALL_LOG); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.WRITE_CALL_LOG)) - permissionNames.add(Manifest.permission.WRITE_CALL_LOG); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.ADD_VOICEMAIL)) - permissionNames.add(Manifest.permission.ADD_VOICEMAIL); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.USE_SIP)) - permissionNames.add(Manifest.permission.USE_SIP); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasPermissionInManifest(context, permissionNames, Manifest.permission.ANSWER_PHONE_CALLS)) - permissionNames.add(Manifest.permission.ANSWER_PHONE_CALLS); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_PHONE_STATE + ) + ) permissionNames.add(Manifest.permission.READ_PHONE_STATE); + + if ( + android.os.Build.VERSION.SDK_INT > Build.VERSION_CODES.Q && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_PHONE_NUMBERS + ) + ) permissionNames.add(Manifest.permission.READ_PHONE_NUMBERS); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.CALL_PHONE + ) + ) permissionNames.add(Manifest.permission.CALL_PHONE); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_CALL_LOG + ) + ) permissionNames.add(Manifest.permission.READ_CALL_LOG); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.WRITE_CALL_LOG + ) + ) permissionNames.add(Manifest.permission.WRITE_CALL_LOG); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ADD_VOICEMAIL + ) + ) permissionNames.add(Manifest.permission.ADD_VOICEMAIL); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.USE_SIP + ) + ) permissionNames.add(Manifest.permission.USE_SIP); + + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ANSWER_PHONE_CALLS + ) + ) permissionNames.add(Manifest.permission.ANSWER_PHONE_CALLS); break; - case PermissionConstants.PERMISSION_GROUP_SENSORS: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.BODY_SENSORS)) { + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.BODY_SENSORS + ) + ) { permissionNames.add(Manifest.permission.BODY_SENSORS); } } break; case PermissionConstants.PERMISSION_GROUP_SENSORS_ALWAYS: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.BODY_SENSORS_BACKGROUND)) { - permissionNames.add(Manifest.permission.BODY_SENSORS_BACKGROUND); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.BODY_SENSORS_BACKGROUND + ) + ) { + permissionNames.add( + Manifest.permission.BODY_SENSORS_BACKGROUND + ); } } break; case PermissionConstants.PERMISSION_GROUP_SMS: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.SEND_SMS)) - permissionNames.add(Manifest.permission.SEND_SMS); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.RECEIVE_SMS)) - permissionNames.add(Manifest.permission.RECEIVE_SMS); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_SMS)) - permissionNames.add(Manifest.permission.READ_SMS); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.RECEIVE_WAP_PUSH)) - permissionNames.add(Manifest.permission.RECEIVE_WAP_PUSH); - - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.RECEIVE_MMS)) - permissionNames.add(Manifest.permission.RECEIVE_MMS); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.SEND_SMS + ) + ) permissionNames.add(Manifest.permission.SEND_SMS); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.RECEIVE_SMS + ) + ) permissionNames.add(Manifest.permission.RECEIVE_SMS); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_SMS + ) + ) permissionNames.add(Manifest.permission.READ_SMS); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.RECEIVE_WAP_PUSH + ) + ) permissionNames.add(Manifest.permission.RECEIVE_WAP_PUSH); + + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.RECEIVE_MMS + ) + ) permissionNames.add(Manifest.permission.RECEIVE_MMS); break; - case PermissionConstants.PERMISSION_GROUP_STORAGE: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_EXTERNAL_STORAGE)) - permissionNames.add(Manifest.permission.READ_EXTERNAL_STORAGE); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && Environment.isExternalStorageLegacy())) { - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.WRITE_EXTERNAL_STORAGE)) - permissionNames.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + ) permissionNames.add( + Manifest.permission.READ_EXTERNAL_STORAGE + ); + + if ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || + (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && + Environment.isExternalStorageLegacy()) + ) { + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + ) permissionNames.add( + Manifest.permission.WRITE_EXTERNAL_STORAGE + ); break; } break; - case PermissionConstants.PERMISSION_GROUP_IGNORE_BATTERY_OPTIMIZATIONS: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && hasPermissionInManifest(context, permissionNames, Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)) - permissionNames.add(Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + ) + ) permissionNames.add( + Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + ); break; - case PermissionConstants.PERMISSION_GROUP_ACCESS_MEDIA_LOCATION: // The ACCESS_MEDIA_LOCATION permission is introduced in Android Q, meaning we should // not handle permissions on pre Android Q devices. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null; - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.ACCESS_MEDIA_LOCATION)) - permissionNames.add(Manifest.permission.ACCESS_MEDIA_LOCATION); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ACCESS_MEDIA_LOCATION + ) + ) permissionNames.add( + Manifest.permission.ACCESS_MEDIA_LOCATION + ); break; - case PermissionConstants.PERMISSION_GROUP_ACTIVITY_RECOGNITION: // The ACTIVITY_RECOGNITION permission is introduced in Android Q, meaning we should // not handle permissions on pre Android Q devices. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null; - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.ACTIVITY_RECOGNITION)) - permissionNames.add(Manifest.permission.ACTIVITY_RECOGNITION); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ACTIVITY_RECOGNITION + ) + ) permissionNames.add(Manifest.permission.ACTIVITY_RECOGNITION); break; - case PermissionConstants.PERMISSION_GROUP_BLUETOOTH: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.BLUETOOTH)) - permissionNames.add(Manifest.permission.BLUETOOTH); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.BLUETOOTH + ) + ) permissionNames.add(Manifest.permission.BLUETOOTH); break; - case PermissionConstants.PERMISSION_GROUP_MANAGE_EXTERNAL_STORAGE: // The MANAGE_EXTERNAL_STORAGE permission is introduced in Android R, meaning we should // not handle permissions on pre Android R devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hasPermissionInManifest(context, permissionNames, Manifest.permission.MANAGE_EXTERNAL_STORAGE)) - permissionNames.add(Manifest.permission.MANAGE_EXTERNAL_STORAGE); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.MANAGE_EXTERNAL_STORAGE + ) + ) permissionNames.add( + Manifest.permission.MANAGE_EXTERNAL_STORAGE + ); break; - case PermissionConstants.PERMISSION_GROUP_SYSTEM_ALERT_WINDOW: - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.SYSTEM_ALERT_WINDOW)) - permissionNames.add(Manifest.permission.SYSTEM_ALERT_WINDOW); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.SYSTEM_ALERT_WINDOW + ) + ) permissionNames.add(Manifest.permission.SYSTEM_ALERT_WINDOW); break; - case PermissionConstants.PERMISSION_GROUP_REQUEST_INSTALL_PACKAGES: // The REQUEST_INSTALL_PACKAGES permission is introduced in Android M, meaning we should // not handle permissions on pre Android M devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && hasPermissionInManifest(context, permissionNames, Manifest.permission.REQUEST_INSTALL_PACKAGES)) - permissionNames.add(Manifest.permission.REQUEST_INSTALL_PACKAGES); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.REQUEST_INSTALL_PACKAGES + ) + ) permissionNames.add( + Manifest.permission.REQUEST_INSTALL_PACKAGES + ); break; case PermissionConstants.PERMISSION_GROUP_ACCESS_NOTIFICATION_POLICY: // The REQUEST_NOTIFICATION_POLICY permission is introduced in Android M, meaning we should // not handle permissions on pre Android M devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && hasPermissionInManifest(context, permissionNames, Manifest.permission.ACCESS_NOTIFICATION_POLICY)) - permissionNames.add(Manifest.permission.ACCESS_NOTIFICATION_POLICY); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ACCESS_NOTIFICATION_POLICY + ) + ) permissionNames.add( + Manifest.permission.ACCESS_NOTIFICATION_POLICY + ); break; case PermissionConstants.PERMISSION_GROUP_BLUETOOTH_SCAN: { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // The BLUETOOTH_SCAN permission is introduced in Android S, meaning we should // not handle permissions on pre Android S devices. - String result = determineBluetoothPermission(context, Manifest.permission.BLUETOOTH_SCAN); + String result = determineBluetoothPermission( + context, + Manifest.permission.BLUETOOTH_SCAN + ); if (result != null) { permissionNames.add(result); @@ -297,7 +506,10 @@ static List getManifestNames(Context context, @PermissionConstants.Permi if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // The BLUETOOTH_ADVERTISE permission is introduced in Android S, meaning we should // not handle permissions on pre Android S devices. - String result = determineBluetoothPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE); + String result = determineBluetoothPermission( + context, + Manifest.permission.BLUETOOTH_ADVERTISE + ); if (result != null) { permissionNames.add(result); @@ -310,7 +522,10 @@ static List getManifestNames(Context context, @PermissionConstants.Permi if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // The BLUETOOTH_CONNECT permission is introduced in Android S, meaning we should // not handle permissions on pre Android S devices. - String result = determineBluetoothPermission(context, Manifest.permission.BLUETOOTH_CONNECT); + String result = determineBluetoothPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT + ); if (result != null) { permissionNames.add(result); @@ -322,37 +537,85 @@ static List getManifestNames(Context context, @PermissionConstants.Permi case PermissionConstants.PERMISSION_GROUP_NOTIFICATION: // The POST_NOTIFICATIONS permission is introduced in Android TIRAMISU, meaning we should // not handle permissions on pre Android TIRAMISU devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && hasPermissionInManifest(context, permissionNames, Manifest.permission.POST_NOTIFICATIONS)) - permissionNames.add(Manifest.permission.POST_NOTIFICATIONS); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.POST_NOTIFICATIONS + ) + ) permissionNames.add(Manifest.permission.POST_NOTIFICATIONS); break; case PermissionConstants.PERMISSION_GROUP_NEARBY_WIFI_DEVICES: // The NEARBY_WIFI_DEVICES permission is introduced in Android TIRAMISU, meaning we should // not handle permissions on pre Android TIRAMISU devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && hasPermissionInManifest(context, permissionNames, Manifest.permission.NEARBY_WIFI_DEVICES)) - permissionNames.add(Manifest.permission.NEARBY_WIFI_DEVICES); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.NEARBY_WIFI_DEVICES + ) + ) permissionNames.add(Manifest.permission.NEARBY_WIFI_DEVICES); break; case PermissionConstants.PERMISSION_GROUP_PHOTOS: // The READ_MEDIA_IMAGES permission is introduced in Android TIRAMISU, meaning we should // not handle permissions on pre Android TIRAMISU devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_MEDIA_IMAGES)) - permissionNames.add(Manifest.permission.READ_MEDIA_IMAGES); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_MEDIA_IMAGES + ) + ) permissionNames.add(Manifest.permission.READ_MEDIA_IMAGES); break; case PermissionConstants.PERMISSION_GROUP_VIDEOS: // The READ_MEDIA_VIDEOS permission is introduced in Android TIRAMISU, meaning we should // not handle permissions on pre Android TIRAMISU devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_MEDIA_VIDEO)) - permissionNames.add(Manifest.permission.READ_MEDIA_VIDEO); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_MEDIA_VIDEO + ) + ) permissionNames.add(Manifest.permission.READ_MEDIA_VIDEO); break; case PermissionConstants.PERMISSION_GROUP_AUDIO: // The READ_MEDIA_AUDIO permission is introduced in Android TIRAMISU, meaning we should // not handle permissions on pre Android TIRAMISU devices. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && hasPermissionInManifest(context, permissionNames, Manifest.permission.READ_MEDIA_AUDIO)) - permissionNames.add(Manifest.permission.READ_MEDIA_AUDIO); + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.READ_MEDIA_AUDIO + ) + ) permissionNames.add(Manifest.permission.READ_MEDIA_AUDIO); break; case PermissionConstants.PERMISSION_GROUP_SCHEDULE_EXACT_ALARM: // The SCHEDULE_EXACT_ALARM permission is introduced in Android S, before Android 31 it should alway return Granted - if (hasPermissionInManifest(context, permissionNames, Manifest.permission.SCHEDULE_EXACT_ALARM)) - permissionNames.add(Manifest.permission.SCHEDULE_EXACT_ALARM); + if ( + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.SCHEDULE_EXACT_ALARM + ) + ) permissionNames.add(Manifest.permission.SCHEDULE_EXACT_ALARM); + break; + case PermissionConstants.PERMISSION_GROUP_ACCESS_LOCAL_NETWORK: + // The ACCESS_LOCAL_NETWORK permission is introduced in API level 37 (Cinnamon Bun) + // API 36 was allowed but only with e.g. NEARBY_WIFI_DEVICES, so we should not handle permissions on pre Android 37 devices. + // Prior to 36, this was always allowed. + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && + hasPermissionInManifest( + context, + permissionNames, + Manifest.permission.ACCESS_LOCAL_NETWORK + ) + ) permissionNames.add(Manifest.permission.ACCESS_LOCAL_NETWORK); break; case PermissionConstants.PERMISSION_GROUP_MEDIA_LIBRARY: case PermissionConstants.PERMISSION_GROUP_REMINDERS: @@ -363,7 +626,11 @@ static List getManifestNames(Context context, @PermissionConstants.Permi return permissionNames; } - private static boolean hasPermissionInManifest(Context context, ArrayList confirmedPermissions, String permission) { + private static boolean hasPermissionInManifest( + Context context, + ArrayList confirmedPermissions, + String permission + ) { try { if (confirmedPermissions != null) { for (String r : confirmedPermissions) { @@ -374,36 +641,50 @@ private static boolean hasPermissionInManifest(Context context, ArrayList(Arrays.asList(info.requestedPermissions)); + confirmedPermissions = new ArrayList<>( + Arrays.asList(info.requestedPermissions) + ); for (String r : confirmedPermissions) { if (r.equals(permission)) { return true; } } } catch (Exception ex) { - Log.d(PermissionConstants.LOG_TAG, "Unable to check manifest for permission: ", ex); + Log.d( + PermissionConstants.LOG_TAG, + "Unable to check manifest for permission: ", + ex + ); } return false; } /** - * 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: @@ -420,37 +701,63 @@ private static boolean hasPermissionInManifest(Context context, ArrayList * 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}. @@ -459,103 +766,121 @@ private static boolean hasPermissionInManifest(Context context, ArrayList statuses) { - if (statuses.contains(PermissionConstants.PERMISSION_STATUS_NEVER_ASK_AGAIN)) - return PermissionConstants.PERMISSION_STATUS_NEVER_ASK_AGAIN; - if (statuses.contains(PermissionConstants.PERMISSION_STATUS_RESTRICTED)) - return PermissionConstants.PERMISSION_STATUS_RESTRICTED; - if (statuses.contains(PermissionConstants.PERMISSION_STATUS_DENIED)) - return PermissionConstants.PERMISSION_STATUS_DENIED; - if (statuses.contains(PermissionConstants.PERMISSION_STATUS_LIMITED)) - return PermissionConstants.PERMISSION_STATUS_LIMITED; - return PermissionConstants.PERMISSION_STATUS_GRANTED; - } - - @NonNull - @PermissionConstants.PermissionStatus - static Integer strictestStatus( - final @Nullable @PermissionConstants.PermissionStatus Integer statusA, - final @Nullable @PermissionConstants.PermissionStatus Integer statusB) { - - final Collection<@PermissionConstants.PermissionStatus Integer> statuses = new HashSet<>(); - statuses.add(statusA); - statuses.add(statusB); - 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) { + if (activity == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return PermissionConstants.PERMISSION_STATUS_DENIED; } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + 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; } - 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) { + 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 (wasDeniedBefore && isDenied) { + 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; } - @RequiresApi(api = Build.VERSION_CODES.M) - static boolean isNeverAskAgainSelected( - @NonNull final Activity activity, - final String name) { + @NonNull + @PermissionConstants.PermissionStatus + static Integer strictestStatus( + final @NonNull Collection< + @PermissionConstants.PermissionStatus Integer + > statuses + ) { + if ( + statuses.contains( + PermissionConstants.PERMISSION_STATUS_NEVER_ASK_AGAIN + ) + ) return PermissionConstants.PERMISSION_STATUS_NEVER_ASK_AGAIN; + if ( + statuses.contains(PermissionConstants.PERMISSION_STATUS_RESTRICTED) + ) return PermissionConstants.PERMISSION_STATUS_RESTRICTED; + if ( + statuses.contains(PermissionConstants.PERMISSION_STATUS_DENIED) + ) return PermissionConstants.PERMISSION_STATUS_DENIED; + if ( + statuses.contains(PermissionConstants.PERMISSION_STATUS_LIMITED) + ) return PermissionConstants.PERMISSION_STATUS_LIMITED; + return PermissionConstants.PERMISSION_STATUS_GRANTED; + } - final boolean shouldShowRequestPermissionRationale = ActivityCompat.shouldShowRequestPermissionRationale(activity, name); - return !shouldShowRequestPermissionRationale; + @NonNull + @PermissionConstants.PermissionStatus + static Integer strictestStatus( + final @Nullable @PermissionConstants.PermissionStatus Integer statusA, + final @Nullable @PermissionConstants.PermissionStatus Integer statusB + ) { + final Collection< + @PermissionConstants.PermissionStatus Integer + > statuses = new HashSet<>(); + statuses.add(statusA); + statuses.add(statusB); + return strictestStatus(statuses); } - private static String determineBluetoothPermission(Context context, String permission) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasPermissionInManifest(context, null, permission)) { + + private static String determineBluetoothPermission( + Context context, + String permission + ) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + hasPermissionInManifest(context, null, permission) + ) { return permission; } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - if (hasPermissionInManifest(context, null, Manifest.permission.ACCESS_FINE_LOCATION)) { + if ( + hasPermissionInManifest( + context, + null, + Manifest.permission.ACCESS_FINE_LOCATION + ) + ) { return Manifest.permission.ACCESS_FINE_LOCATION; - } else if (hasPermissionInManifest(context, null, Manifest.permission.ACCESS_COARSE_LOCATION)) { + } else if ( + hasPermissionInManifest( + context, + null, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) { return Manifest.permission.ACCESS_COARSE_LOCATION; } return null; - } else if (hasPermissionInManifest(context, null, Manifest.permission.ACCESS_FINE_LOCATION)) { + } else if ( + hasPermissionInManifest( + context, + null, + Manifest.permission.ACCESS_FINE_LOCATION + ) + ) { return Manifest.permission.ACCESS_FINE_LOCATION; } @@ -565,13 +890,22 @@ private static String determineBluetoothPermission(Context context, String permi // Suppress deprecation warnings since its purpose is to support to be backwards compatible with // pre TIRAMISU versions of Android @SuppressWarnings("deprecation") - private static PackageInfo getPackageInfo(Context context) throws PackageManager.NameNotFoundException { + private static PackageInfo getPackageInfo(Context context) + throws PackageManager.NameNotFoundException { final PackageManager pm = context.getPackageManager(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return pm.getPackageInfo(context.getPackageName(), PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS)); + return pm.getPackageInfo( + context.getPackageName(), + PackageManager.PackageInfoFlags.of( + PackageManager.GET_PERMISSIONS + ) + ); } else { - return pm.getPackageInfo(context.getPackageName(), PackageManager.GET_PERMISSIONS); + return pm.getPackageInfo( + context.getPackageName(), + PackageManager.GET_PERMISSIONS + ); } } @@ -587,9 +921,16 @@ private static PackageInfo getPackageInfo(Context context) throws PackageManager * @param permissionName the name of the permission * @return whether the permission was denied in the past */ - private static boolean wasPermissionDeniedBefore(final Context context, final String permissionName) { - final SharedPreferences sharedPreferences = context.getSharedPreferences(permissionName, Context.MODE_PRIVATE); - return sharedPreferences.getBoolean(SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY, false); + private static boolean wasPermissionDeniedBefore( + final Context context, + final String permissionName + ) { + final SharedPreferences sharedPreferences = + context.getSharedPreferences(permissionName, Context.MODE_PRIVATE); + return sharedPreferences.getBoolean( + SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY, + false + ); } /** @@ -602,8 +943,49 @@ private static boolean wasPermissionDeniedBefore(final Context context, final St * @param context context needed for accessing shared preferences. * @param permissionName the name of the permission */ - private static void setPermissionDenied(final Context context, final String permissionName) { - final SharedPreferences sharedPreferences = context.getSharedPreferences(permissionName, Context.MODE_PRIVATE); - sharedPreferences.edit().putBoolean(SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY, true).apply(); + private static void setPermissionDenied( + final Context context, + final String permissionName + ) { + final SharedPreferences sharedPreferences = + context.getSharedPreferences(permissionName, Context.MODE_PRIVATE); + sharedPreferences + .edit() + .putBoolean( + SHARED_PREFERENCES_PERMISSION_WAS_DENIED_BEFORE_KEY, + true + ) + .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/example/android/app/build.gradle b/permission_handler_android/example/android/app/build.gradle deleted file mode 100644 index 04ce10422..000000000 --- a/permission_handler_android/example/android/app/build.gradle +++ /dev/null @@ -1,53 +0,0 @@ -plugins { - id "com.android.application" - id "dev.flutter.flutter-gradle-plugin" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -android { - namespace 'com.baseflow.permissionhandler.example' - compileSdkVersion 35 - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.baseflow.permissionhandler.example" - minSdkVersion flutter.minSdkVersion - targetSdkVersion 35 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} diff --git a/permission_handler_android/example/android/app/build.gradle.kts b/permission_handler_android/example/android/app/build.gradle.kts new file mode 100644 index 000000000..602003d43 --- /dev/null +++ b/permission_handler_android/example/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.baseflow.permissionhandler.example" + compileSdk = 37 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.baseflow.permissionhandler.example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = 35 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/permission_handler_android/example/android/app/src/debug/AndroidManifest.xml b/permission_handler_android/example/android/app/src/debug/AndroidManifest.xml index d692acb24..f880684a6 100644 --- a/permission_handler_android/example/android/app/src/debug/AndroidManifest.xml +++ b/permission_handler_android/example/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/permission_handler_android/example/android/app/src/profile/AndroidManifest.xml b/permission_handler_android/example/android/app/src/profile/AndroidManifest.xml index acdbca678..9b6b110c8 100644 --- a/permission_handler_android/example/android/app/src/profile/AndroidManifest.xml +++ b/permission_handler_android/example/android/app/src/profile/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/permission_handler_android/example/android/build.gradle b/permission_handler_android/example/android/build.gradle deleted file mode 100644 index 0ed2bee85..000000000 --- a/permission_handler_android/example/android/build.gradle +++ /dev/null @@ -1,30 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} - -// Build the plugin project with warnings enabled. This is here rather than -// in the plugin itself to avoid breaking clients that have different -// warnings (e.g., deprecation warnings from a newer SDK than this project -// builds with). -gradle.projectsEvaluated { - project(":permission_handler_android") { - tasks.withType(JavaCompile) { - options.compilerArgs << "-Xlint:all" << "-Werror" - } - } -} diff --git a/permission_handler_android/example/android/build.gradle.kts b/permission_handler_android/example/android/build.gradle.kts new file mode 100644 index 000000000..dbee657bb --- /dev/null +++ b/permission_handler_android/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/permission_handler_android/example/android/gradle.properties b/permission_handler_android/example/android/gradle.properties index 598d13fee..d5da7278a 100644 --- a/permission_handler_android/example/android/gradle.properties +++ b/permission_handler_android/example/android/gradle.properties @@ -1,3 +1,6 @@ -org.gradle.jvmargs=-Xmx4G +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/permission_handler_android/example/android/gradle/wrapper/gradle-wrapper.properties b/permission_handler_android/example/android/gradle/wrapper/gradle-wrapper.properties index db18181ac..2e1113280 100644 --- a/permission_handler_android/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/permission_handler_android/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/permission_handler_android/example/android/settings.gradle b/permission_handler_android/example/android/settings.gradle deleted file mode 100644 index 56eb85cb1..000000000 --- a/permission_handler_android/example/android/settings.gradle +++ /dev/null @@ -1,24 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.0" apply false -} - -include ":app" \ No newline at end of file diff --git a/permission_handler_android/example/android/settings.gradle.kts b/permission_handler_android/example/android/settings.gradle.kts new file mode 100644 index 000000000..c21f0c5b4 --- /dev/null +++ b/permission_handler_android/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/permission_handler_android/example/pubspec.yaml b/permission_handler_android/example/pubspec.yaml index e76011501..7d2894a03 100644 --- a/permission_handler_android/example/pubspec.yaml +++ b/permission_handler_android/example/pubspec.yaml @@ -3,17 +3,13 @@ description: Demonstrates how to use the permission_handler_android plugin. environment: sdk: ^3.7.0 +resolution: workspace dependencies: baseflow_plugin_template: ^2.1.2 flutter: sdk: flutter - permission_handler_platform_interface: ^4.2.0 - -dev_dependencies: - flutter_test: - sdk: flutter - + permission_handler_platform_interface: ^4.4.0 permission_handler_android: # When depending on this package from a real application you should use: # permission_handler_android: ^x.y.z @@ -22,6 +18,10 @@ dev_dependencies: # the parent directory to use the current plugin's version. path: ../ +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 url_launcher: ^6.0.12 flutter: diff --git a/permission_handler_android/pubspec.yaml b/permission_handler_android/pubspec.yaml index b16ec5278..dcae7813d 100644 --- a/permission_handler_android/pubspec.yaml +++ b/permission_handler_android/pubspec.yaml @@ -1,11 +1,12 @@ 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: 13.0.1 +version: 14.1.0 environment: - sdk: ^3.5.0 + sdk: ^3.6.0 flutter: ">=3.24.0" +resolution: workspace flutter: plugin: @@ -18,7 +19,7 @@ flutter: dependencies: flutter: sdk: flutter - permission_handler_platform_interface: ^4.2.0 + permission_handler_platform_interface: ^4.4.0 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/permission_handler_apple/CHANGELOG.md b/permission_handler_apple/CHANGELOG.md index fbd51929f..aab215b32 100644 --- a/permission_handler_apple/CHANGELOG.md +++ b/permission_handler_apple/CHANGELOG.md @@ -1,100 +1,172 @@ +## 9.6.1 + +- Fixes small mistakes in the README.md documentation. + +## 9.6.0 + +- Adds opt-in per-flavor permissions for Swift Package Manager builds. An app can declare a + `permission_handler.yaml` next to its `pubspec.yaml` mapping each flavor to the `Info.plist` that + defines it, and only the selected flavor's permissions are compiled in — a permission declared by + `dev` can no longer reach a `prod` binary. Without this file the previous behaviour is unchanged. + This is a Swift Package Manager feature: CocoaPods builds set the `PERMISSION_*` macros from the + `Podfile` and are unaffected, including by the build phase below. +- Adds `dart run permission_handler_apple:select `, which records the active flavor and + clears the caches that would otherwise keep serving the previously resolved permissions. Xcode + does not re-evaluate a package manifest when an environment variable or the selection changes, so + this step is required when switching flavors. `select` is also the only YAML reader: a Swift + package manifest cannot parse YAML, so the command translates the config into a generated + `ios/Flutter/permission_handler.resolved.json` (gitignore it) that the manifest and the build + phase read with their native JSON parsers. A translation older than the YAML fails the build + instead of shipping stale permissions. +- Adds `tool/verify_flavor_selection.sh`, a build phase for the app target that fails the build when + the selected flavor does not match the configuration being built. A package manifest is evaluated + once and cannot detect that its own result went stale, so this is what catches a forgotten + `select`. It is a no-op without a `permission_handler.yaml` and on CocoaPods builds. +- Adds the `PERMISSION_HANDLER_FLAVOR` and `PERMISSION_HANDLER_CONFIG` environment variables to set + the active flavor and the configuration file location explicitly. +- `select` validates the configuration up front and refuses anything ambiguous: a build + configuration claimed by more than one flavor, a `configurations` that is not a list, a + non-string flavor name, or a `strict` that is not a boolean. Being the only reader of the YAML, + it is the only place where these can be reported at all. + +## 9.5.1 + +- Fixes the Swift Package Manager permission auto-detection, which failed to find the host app's + `Info.plist` and silently compiled out every permission. Apps hit this in two ways: the manifest + only ever looked at `ios/Runner/Info.plist`, so build-configuration or flavor specific plists such + as `Info-Debug.plist` were never seen ([#1548](https://github.com/Baseflow/flutter-permission-handler/issues/1548)), + and the app-root lookup could walk past the app entirely. `Info.plist` locations are now resolved + from `INFOPLIST_FILE` in the Xcode project and any `.xcconfig` files, with a scan of `ios/` as a + fallback, and the usage description keys found across them are merged. +- Adds the `PERMISSION_HANDLER_INFO_PLIST` environment variable, which points the manifest at one or + more `Info.plist` files and replaces automatic discovery. This is required for builds started from + Xcode.app, which run with `/` as their working directory and cannot be detected automatically. +- Adds the `PERMISSION_HANDLER_VERBOSE` environment variable, which logs the app root, the + `Info.plist` files used, and the resolved `PERMISSION_*` macros. +- Emits a warning when no `Info.plist` can be located, instead of silently disabling every + permission. Note that Xcode discards Swift package manifest output, so this warning is only + visible through the `swift package` command line. + +## 9.5.0 + +- Adds support for the new Android 17 permission `ACCESS_LOCAL_NETWORK`. + +## 9.4.10 + +- Fixed Info.plist lookup in Package.swift to auto-apply permissions. +- You may see build log "Plugin permission_handler_apple has a Package.swift for ios but is missing a dependency on FlutterFramework". FlutterFramework hasn't been added intentionally because it requires to bump flutter constraint to >=3.41.0. + +## 9.4.9 + +- Rewrites copyleft code from stackoverflow to fix compliance issue. + +## 9.4.8 + +- Adds Swift Package Manager (SPM) support for Flutter 3.24+. Permissions are + enabled automatically based on usage description keys present in `Info.plist` + — no additional configuration required beyond clearing DerivedData once after + changes: `rm -rf ~/Library/Developer/Xcode/DerivedData`. +- Moves ObjC sources to SPM-compatible layout (`Sources/permission_handler_apple/`). + CocoaPods continues to work unchanged. +- Bumps minimum iOS deployment target to 12.0. + ## 9.4.7 -* Increases minimum supported Flutter version to 3.3.0, and removes code only +- Increases minimum supported Flutter version to 3.3.0, and removes code only required for iOS versions prior to iOS 11. ## 9.4.6 -* Adds the ability to handle `CNAuthorizationStatusLimited` introduced in ios18 +- Adds the ability to handle `CNAuthorizationStatusLimited` introduced in ios18 ## 9.4.5 -* Fixes issue #1002, Xcode warning of the unresponsive of main thread when checking isLocationEnabled. +- Fixes issue #1002, Xcode warning of the unresponsive of main thread when checking isLocationEnabled. ## 9.4.4 -* Fixes potentially-nil return type of EventPermissionStrategy#getEntityType. -* * Fixes typo in comment for full calendar access. +- Fixes potentially-nil return type of EventPermissionStrategy#getEntityType. +- - Fixes typo in comment for full calendar access. ## 9.4.3 -* Adds the `PERMISSION_LOCATION_WHENINUSE` macro, which can be used instead of +- Adds the `PERMISSION_LOCATION_WHENINUSE` macro, which can be used instead of the `PERMISSION_LOCATION` macro, and exclusively enables the `requestWhenInUseAuthorization` and remove the `requestAlwaysAuthorization` when requesting location permission. -* Improves error handling when `Info.plist` doesn't contain the correct declarations. -* Adds support for the `NSLocationAlwaysAndWhenInUseUsageDescription` property list +- Improves error handling when `Info.plist` doesn't contain the correct declarations. +- Adds support for the `NSLocationAlwaysAndWhenInUseUsageDescription` property list key. ## 9.4.2 -* Updates the privacy manifest to include the use of the `NSUserDefaults` API. +- Updates the privacy manifest to include the use of the `NSUserDefaults` API. The permission_handler stores a boolean value to track if permission to always access the device location has been requested. ## 9.4.1 -* Adds empty privacy manifest. +- Adds empty privacy manifest. ## 9.4.0 -* Adds a new permission `Permission.backgroundRefresh` to check the background refresh permission status. +- Adds a new permission `Permission.backgroundRefresh` to check the background refresh permission status. ## 9.3.1 -* Updates plist key from `NSPhotoLibraryUsageDescription` to `NSPhotoLibraryAddUsageDescription`. +- Updates plist key from `NSPhotoLibraryUsageDescription` to `NSPhotoLibraryAddUsageDescription`. ## 9.3.0 -* Adds support to request authorization to access SiriKit via the `Permission.assistant` permission. +- Adds support to request authorization to access SiriKit via the `Permission.assistant` permission. ## 9.2.0 -* Adds the support for `Permission.calendarWriteOnly` and `Permission.calendarFullAccess` permissions which are introduced in iOS 17+. +- Adds the support for `Permission.calendarWriteOnly` and `Permission.calendarFullAccess` permissions which are introduced in iOS 17+. ## 9.1.4 -* Adds checking whether Bluetooth service is enabled through `Permission.bluetooth.serviceStatus`. +- Adds checking whether Bluetooth service is enabled through `Permission.bluetooth.serviceStatus`. ## 9.1.3 -* Fixes an issue where the `Permission.location.request()`, `Permission.locationWhenInUse.request()` and `Permission.locationAlways.request()` calls returned `PermissionStatus.denied` regardless of the actual permission status. +- Fixes an issue where the `Permission.location.request()`, `Permission.locationWhenInUse.request()` and `Permission.locationAlways.request()` calls returned `PermissionStatus.denied` regardless of the actual permission status. ## 9.1.2 -* Fixes an issue where the `Permission.locationAlways.request()` call hangs when the application was granted "Allow once" permissions for fetching location coordinates. +- Fixes an issue where the `Permission.locationAlways.request()` call hangs when the application was granted "Allow once" permissions for fetching location coordinates. ## 9.1.1 -* Adds the new Android 13 permission "BODY_SENSORS_BACKGROUND" to PermissionHandlerEnums.h. +- Adds the new Android 13 permission "BODY_SENSORS_BACKGROUND" to PermissionHandlerEnums.h. ## 9.1.0 -* Adds the "Provisional" permission status which is introduced in iOS 12+. +- Adds the "Provisional" permission status which is introduced in iOS 12+. ## 9.0.8 -* Adds missing return statement causing the permission_handler to freeze when already requesting permissions. +- Adds missing return statement causing the permission_handler to freeze when already requesting permissions. ## 9.0.7 -* Adds new Android 13 permissions "SCHEDULE_EXACT_ALARM, READ_MEDIA_IMAGES, READ_MEDIA_VIDEO and READ_MEDIA_AUDIO" to PermissionHandlerEnums.h +- Adds new Android 13 permissions "SCHEDULE_EXACT_ALARM, READ_MEDIA_IMAGES, READ_MEDIA_VIDEO and READ_MEDIA_AUDIO" to PermissionHandlerEnums.h ## 9.0.6 -* Prevents appearing popup that asks to turn on Bluetooth on iOS +- Prevents appearing popup that asks to turn on Bluetooth on iOS ## 9.0.5 -* Adds new Android 13 NEARBY_WIFI_DEVICES permission to PermissionHandlerEnums.h +- Adds new Android 13 NEARBY_WIFI_DEVICES permission to PermissionHandlerEnums.h ## 9.0.4 -* Adds flag inside `UserDefaults` to save whether `locationAlways` has already been requested and prevent further requests, which would be left unanswered by the system. +- Adds flag inside `UserDefaults` to save whether `locationAlways` has already been requested and prevent further requests, which would be left unanswered by the system. ## 9.0.3 -* Ensures a request for `locationAlways` permission returns a result unblocking the permission request and preventing the `ERROR_ALREADY_REQUESTING_PERMISSIONS` error for subsequent permission requests. +- Ensures a request for `locationAlways` permission returns a result unblocking the permission request and preventing the `ERROR_ALREADY_REQUESTING_PERMISSIONS` error for subsequent permission requests. ## 9.0.2 -* Moves Apple implementation into its own package. +- Moves Apple implementation into its own package. diff --git a/permission_handler_apple/README.md b/permission_handler_apple/README.md index 22d8adfd2..23c47cd4c 100644 --- a/permission_handler_apple/README.md +++ b/permission_handler_apple/README.md @@ -10,6 +10,185 @@ Since version 9.1.0 of the [permission_handler](https://pub.dev/packages/permiss More detailed instructions on using the API can be found in the [README.md](../permission_handler/README.md) of the [permission_handler](https://pub.dev/packages/permission_handler) package. +## Swift Package Manager + +Only the permissions your app actually uses are compiled into the binary. Referencing an iOS +permission API you have no usage description for is grounds for App Store rejection +(`ITMS-90683`), so each permission is guarded by a `PERMISSION_*` macro. + +Under CocoaPods you set those macros yourself, in the `GCC_PREPROCESSOR_DEFINITIONS` block of your +`Podfile`. Under Swift Package Manager the package manifest derives them instead: it locates your +app's `Info.plist` files and enables a permission when the matching `NS*UsageDescription` key is +present. `INFOPLIST_FILE` is read from your Xcode project and `.xcconfig` files, so +build-configuration and flavor specific plists (`Info-Debug.plist`, `Info-dev.plist`, +`Runner/Info-$(CONFIGURATION).plist`, …) are all picked up. + +**Keys are merged across every configuration.** A package manifest is evaluated once and cannot +vary its settings per build configuration, so a permission declared only in `Info-dev.plist` is +compiled into your release binary too. Declare flavors (below) when that matters. + +**Changes are cached.** The manifest is not re-evaluated when an `Info.plist` or an environment +variable changes. Clear DerivedData once afterwards: + +```bash +rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +### Per-flavor permissions + +> Swift Package Manager only. Under CocoaPods the macros come from your `Podfile`, where you can +> already set them per configuration; `permission_handler.yaml` is ignored and the build phase +> below is a no-op. + +If your flavors need different permissions — a `dev` build that scans QR codes, a `prod` build that +does not — merging is wrong: it compiles the camera code into `prod` too, which is what +`ITMS-90683` rejects. Declare a `permission_handler.yaml` next to your `pubspec.yaml`: + +```yaml +strict: true +flavors: + dev: + info-plist: ios/Runner/Info-dev.plist + configurations: + - Debug-dev + - Profile-dev + - Release-dev + prod: + info-plist: ios/Runner/Info-prod.plist + configurations: + - Debug-prod + - Profile-prod + - Release-prod +``` + +Each flavor names the one `Info.plist` that defines it, and nothing is merged: a flavor can never +inherit another flavor's permissions. `configurations` lists the Xcode build configurations that +belong to the flavor, which is how the build phase below knows what to expect. + +`select` validates the file before doing anything else, since it is the only thing that reads the +YAML — a mistake it doesn't catch here has to fail silently later instead: + +- Each build configuration must belong to exactly one flavor. Two flavors listing the same one + leaves the build phase unable to tell which permissions it should ship. +- `configurations` must be a list, even for a single value — `configurations: Debug` (without the + `-`) is rejected rather than silently treated as "no configurations", which would otherwise + disable the build phase's verification for that flavor without any warning. +- Flavor names must be strings. A bare `123:` is read as a YAML integer key and rejected — quote + it (`"123":`) if you actually want that literal name. +- `strict`, if present, must be `true` or `false`. + +Select a flavor before building: + +```bash +dart run permission_handler_apple:select prod +``` + +``` +Usage: dart run permission_handler_apple:select + + --list Show the flavors declared in permission_handler.yaml. + --app= App directory (defaults to the current directory). + --derived-data= Custom DerivedData location, matching xcodebuild's + -derivedDataPath. +``` + +`--app` and `--derived-data` matter mainly in CI, where the command may not run from inside the app +directory and DerivedData may live somewhere other than `~/Library/Developer/Xcode/DerivedData`. + +```bash +flutter run --flavor prod +``` + +Selecting is a separate step because the package manifest is evaluated once, is cached, and is +given none of Xcode's build settings — it cannot tell which configuration is running, and Xcode +will not re-evaluate it just because an environment variable changed. `select` records the choice +*and* clears the caches that would otherwise keep serving the previous flavor's permissions. + +`select` is also the only YAML reader in the pipeline. A Swift package manifest cannot parse YAML — +Foundation has no support for it and a manifest cannot import libraries — so `select` translates +your config into a generated `ios/Flutter/permission_handler.resolved.json` that the manifest and +the build phase read with their native JSON parsers. Never edit that file; if you change +`permission_handler.yaml`, re-run `select`. Building with a translation older than the YAML fails +rather than using stale permissions. The file is plain JSON, so it also doubles as a quick way to +see exactly what `select` resolved, without waiting for a build. + +With `strict: true` (the default) a build whose flavor cannot be determined compiles no permissions +at all, rather than falling back to the union of every flavor. Set `strict: false` to opt into that +fallback instead: the manifest merges every `Info.plist` it can discover, with a warning, the same +as an app with no `permission_handler.yaml` at all. Treat it as a landing pad while adopting +flavors gradually, not a long-term setting — it is the exact leak per-flavor configuration exists +to close. + +#### Fail the build on a stale selection + +Nothing inside the manifest can detect a stale selection, because a cached manifest is not +executed. Add a **Run Script** build phase to your Runner target, and drag it to the *top* of the +phase list so a mismatch fails before anything is compiled: + +```sh +# Resolve the plugin, wherever this project gets it from. +PLUGIN="$SRCROOT/Flutter/ephemeral/Packages/.packages/permission_handler_apple/../.." +[ -d "$PLUGIN/tool" ] || PLUGIN="$SRCROOT/.symlinks/plugins/permission_handler_apple" +[ -f "$PLUGIN/tool/verify_flavor_selection.sh" ] || exit 0 +/bin/sh "$PLUGIN/tool/verify_flavor_selection.sh" +``` + +It runs on every build, where `CONFIGURATION` is available, and fails with an actionable message +when the selected flavor does not match what is being built: + +``` +error: [permission_handler_apple] building "Release-prod" needs the "prod" permission flavor, +but "dev" is selected, so this build would ship dev's permissions. +Run: dart run permission_handler_apple:select prod +``` + +Add these to your `.gitignore` — one records a local choice, the other is generated: + +``` +ios/Flutter/permission_handler.selected +ios/Flutter/permission_handler.resolved.json +``` + +### Environment variables + +Xcode.app does not inherit your shell's environment, so set these with `launchctl setenv` rather +than exporting them, then restart Xcode. + +| Variable | Effect | +| --- | --- | +| `PERMISSION_` | Forces a single permission on (`1`) or off (`0`), overriding everything else. For example `launchctl setenv PERMISSION_CAMERA 0`. | +| `PERMISSION_HANDLER_INFO_PLIST` | A `:`-separated list of `Info.plist` paths. When set, replaces automatic discovery entirely. | +| `PERMISSION_HANDLER_FLAVOR` | The active flavor, overriding the one recorded by `select`. Changing it still needs the caches cleared. | +| `PERMISSION_HANDLER_CONFIG` | Path to `permission_handler.yaml`, for builds that cannot locate the app automatically. | +| `PERMISSION_HANDLER_VERBOSE` | Set to `1` to log the `Info.plist` files used, the app root or active flavor, and the resolved macros. | + +### Builds started from Xcode.app + +Automatic discovery finds your app through the build's working directory, which points at the +Flutter project for `flutter run`, `flutter build ios` and a direct `xcodebuild` invocation. Builds +started from Xcode.app run with `/` as their working directory, and the manifest is given none of +Xcode's build settings, so there is nothing to find the app by. Point it at the plist explicitly: + +```bash +launchctl setenv PERMISSION_HANDLER_INFO_PLIST /absolute/path/to/ios/Runner/Info.plist +rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +If no `Info.plist` is found, every permission is compiled out and permission checks report +`denied`. The manifest warns about this, but Xcode discards Swift package manifest output, so the +warning only reaches you through the command line: + +```bash +cd your_app +PERMISSION_HANDLER_VERBOSE=1 swift package --manifest-cache none \ + --package-path ios/Flutter/ephemeral/Packages/.packages/permission_handler_apple \ + dump-package > /dev/null +``` + +That prints the `Info.plist` files that were used, the app root or the active flavor depending on +whether you declared one, and the value every `PERMISSION_*` macro resolved to — the quickest way +to check what your app will actually be built with. + ## Issues Please file any issues, bugs, or feature requests as an issue on our [GitHub](https://github.com/Baseflow/flutter-permission-handler/issues) page. Commercial support is available, you can contact us at . diff --git a/permission_handler_apple/bin/select.dart b/permission_handler_apple/bin/select.dart new file mode 100644 index 000000000..133899a23 --- /dev/null +++ b/permission_handler_apple/bin/select.dart @@ -0,0 +1,345 @@ +// Selects which flavor's permissions the Swift Package Manager build compiles. +// +// Usage: +// dart run permission_handler_apple:select +// dart run permission_handler_apple:select --list +// +// A Swift package manifest is evaluated once per package resolution and is given +// none of Xcode's build settings, so it cannot know which build configuration is +// running. The flavor therefore has to be chosen before the build, which is what +// this command does: it records the choice and clears the caches that would +// otherwise keep serving the previous flavor's macros. +// +// The configuration lives in permission_handler.yaml, but a Swift package +// manifest cannot parse YAML — Foundation has no YAML support and a manifest +// cannot import libraries for its own evaluation. This command is therefore the +// only YAML reader: it translates the config into a generated +// permission_handler.resolved.json that the manifest and the verification build +// phase consume with their native JSON parsers. The generated file is an +// internal artifact — gitignore it, never edit it. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:yaml/yaml.dart'; + +const _configName = 'permission_handler.yaml'; +const _selectionPath = 'ios/Flutter/permission_handler.selected'; +const _resolvedPath = 'ios/Flutter/permission_handler.resolved.json'; + +/// Caches that keep a previously evaluated manifest alive. +/// +/// Xcode does not re-evaluate a package manifest when an environment variable or +/// the selection file changes — only when these are gone. Clearing +/// `SourcePackages` on its own is not enough; the resolved build description in +/// `XCBuildData` pins the old settings too. +const _derivedDataSubpaths = [ + 'SourcePackages', + 'Build/Intermediates.noindex/XCBuildData', +]; + +void main(List args) { + final flags = {}; + final positional = []; + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == '--list') { + flags['list'] = 'true'; + } else if (arg.startsWith('--app=')) { + flags['app'] = arg.substring(6); + } else if (arg.startsWith('--derived-data=')) { + flags['derived-data'] = arg.substring(15); + } else if (arg == '--help' || arg == '-h') { + stdout.writeln(_usage); + return; + } else if (arg.startsWith('-')) { + _fail('Unknown option "$arg".\n\n$_usage'); + } else { + positional.add(arg); + } + } + + final appRoot = _findAppRoot(flags['app']); + final configFile = File('${appRoot.path}/$_configName'); + if (!configFile.existsSync()) { + _fail('No $_configName found in ${appRoot.path}.\n\n' + 'Create one to describe your flavors:\n$_exampleConfig'); + } + + final config = _readConfig(configFile); + + if (flags.containsKey('list')) { + stdout.writeln('Flavors declared in ${configFile.path}:'); + for (final entry in config.flavors.entries) { + final exists = + File('${appRoot.path}/${entry.value.infoPlist}').existsSync(); + stdout.writeln(' ${entry.key.padRight(12)} ${entry.value.infoPlist}' + '${exists ? '' : ' (missing!)'}'); + } + return; + } + + if (positional.length != 1) { + _fail('Expected exactly one flavor name.\n\n$_usage'); + } + final flavor = positional.single; + + final entry = config.flavors[flavor]; + if (entry == null) { + _fail('Flavor "$flavor" is not declared in ${configFile.path}.\n' + 'Known flavors: ${config.flavors.keys.join(', ')}'); + } + + final plist = File('${appRoot.path}/${entry.infoPlist}'); + if (!plist.existsSync()) { + _fail( + 'Flavor "$flavor" points at ${entry.infoPlist}, which does not exist.'); + } + + _writeResolved(appRoot, config); + + final selection = File('${appRoot.path}/$_selectionPath'); + selection.parent.createSync(recursive: true); + selection.writeAsStringSync('$flavor\n'); + + final cleared = _clearCaches(appRoot, flags['derived-data']); + + stdout.writeln('Selected flavor "$flavor" (${entry.infoPlist}).'); + stdout.writeln(''); + stdout.writeln('Permissions that will be compiled in:'); + final descriptions = _usageDescriptions(plist); + if (descriptions.isEmpty) { + stdout.writeln( + ' (none — ${entry.infoPlist} declares no usage descriptions)'); + } else { + for (final key in descriptions) { + stdout.writeln(' $key'); + } + } + stdout.writeln(''); + stdout.writeln(cleared.isEmpty + ? 'No package caches needed clearing.' + : 'Cleared ${cleared.length} cache location(s) so the manifest is ' + 're-evaluated on the next build.'); +} + +class _Flavor { + const _Flavor(this.infoPlist, this.configurations); + + final String infoPlist; + final List configurations; +} + +class _Config { + const _Config(this.strict, this.flavors); + + final bool strict; + final Map flavors; +} + +/// Walk up looking for a Flutter app: a pubspec.yaml next to an Xcode project. +Directory _findAppRoot(String? override) { + var dir = Directory(override ?? Directory.current.path).absolute; + for (var i = 0; i < 12; i++) { + final hasPubspec = File('${dir.path}/pubspec.yaml').existsSync(); + final iosDir = Directory('${dir.path}/ios'); + final hasProject = iosDir.existsSync() && + iosDir.listSync().any((e) => e.path.endsWith('.xcodeproj')); + if (hasPubspec && hasProject) return dir; + final parent = dir.parent; + if (parent.path == dir.path) break; + dir = parent; + } + _fail('Could not find a Flutter app (a pubspec.yaml next to ios/*.xcodeproj) ' + 'from ${override ?? Directory.current.path}. Pass --app=.'); +} + +_Config _readConfig(File configFile) { + final Object? decoded; + try { + decoded = loadYaml(configFile.readAsStringSync()); + } on YamlException catch (e) { + _fail('${configFile.path} is not valid YAML: ${e.message}'); + } + + if (decoded is! YamlMap) { + _fail('${configFile.path} must contain a YAML mapping.\n\n$_exampleConfig'); + } + final flavors = decoded['flavors']; + if (flavors is! YamlMap || flavors.isEmpty) { + _fail('${configFile.path} declares no "flavors".\n\n$_exampleConfig'); + } + + // This command is the only thing that reads the YAML, so it is the only place + // that can reject a malformed config. Everything downstream sees the + // generated JSON and has no way to tell a deliberate value from a typo, so + // validation that is skipped here is validation that never happens. + final result = {}; + final claimedBy = {}; // build configuration -> flavor + + for (final entry in flavors.entries) { + final key = entry.key; + if (key is! String) { + _fail('Flavor name ${jsonEncode(key)} in ${configFile.path} is not a ' + 'string. Quote it if you meant a literal name: "$key".'); + } + final name = key; + + final value = entry.value; + if (value is! YamlMap || value['info-plist'] is! String) { + _fail('Flavor "$name" in ${configFile.path} has no "info-plist" string.'); + } + + // A scalar here used to become an empty list, which silently disables the + // build phase's mismatch check for every configuration of this flavor. + final configurations = value['configurations']; + if (configurations != null && configurations is! YamlList) { + _fail('Flavor "$name" in ${configFile.path} has a "configurations" that ' + 'is not a list. Write it as:\n' + ' configurations:\n' + ' - Debug-$name\n' + ' - Release-$name'); + } + + final names = + (configurations as YamlList?)?.map((c) => c.toString()).toList() ?? + const []; + + // Two flavors claiming one configuration makes the build phase pick + // whichever comes first and demand that flavor, which would talk the user + // into shipping the other flavor's permissions. + for (final configuration in names) { + final owner = claimedBy[configuration]; + if (owner != null) { + _fail('Build configuration "$configuration" in ${configFile.path} is ' + 'claimed by both "$owner" and "$name". Each configuration must ' + 'belong to exactly one flavor, otherwise the build cannot tell ' + 'which permissions it should ship.'); + } + claimedBy[configuration] = name; + } + + result[name] = _Flavor(value['info-plist'] as String, names); + } + + final strict = decoded['strict']; + if (strict != null && strict is! bool) { + _fail('"strict" in ${configFile.path} must be true or false, not ' + '${jsonEncode(strict.toString())}.'); + } + + return _Config(strict as bool? ?? true, result); +} + +/// Write the generated JSON translation the manifest and build phase read. +/// +/// The camelCase `infoPlist` key is deliberate: it matches what Package.swift +/// and verify_flavor_selection.sh already parse, and this file is not +/// user-facing. +void _writeResolved(Directory appRoot, _Config config) { + final resolved = File('${appRoot.path}/$_resolvedPath'); + resolved.parent.createSync(recursive: true); + resolved.writeAsStringSync( + const JsonEncoder.withIndent(' ').convert({ + 'note': 'Generated by permission_handler_apple:select from ' + '$_configName. Do not edit or commit.', + 'strict': config.strict, + 'flavors': { + for (final entry in config.flavors.entries) + entry.key: { + 'infoPlist': entry.value.infoPlist, + 'configurations': entry.value.configurations, + }, + }, + }), + ); +} + +List _usageDescriptions(File plist) { + final matches = RegExp(r'(NS\w*UsageDescription)') + .allMatches(plist.readAsStringSync()) + .map((m) => m.group(1)!) + .toSet() + .toList() + ..sort(); + return matches; +} + +/// Remove the caches pinning the previously resolved manifest. +List _clearCaches(Directory appRoot, String? derivedDataOverride) { + final cleared = []; + + void remove(String path) { + final dir = Directory(path); + if (!dir.existsSync()) return; + dir.deleteSync(recursive: true); + cleared.add(path); + } + + final home = Platform.environment['HOME']; + if (home != null) { + remove('$home/Library/Caches/org.swift.swiftpm/manifests'); + } + + for (final derivedData in _derivedDataDirs(appRoot, derivedDataOverride)) { + for (final sub in _derivedDataSubpaths) { + remove('${derivedData.path}/$sub'); + } + } + + return cleared; +} + +/// Locate the DerivedData directories belonging to this app. +/// +/// Every Flutter app's Xcode project is called `Runner`, so matching on the +/// directory name would clear unrelated apps' caches. Each DerivedData +/// directory records the workspace it belongs to in its `info.plist`; match on +/// that instead. +List _derivedDataDirs(Directory appRoot, String? override) { + if (override != null) return [Directory(override)]; + + final home = Platform.environment['HOME']; + if (home == null) return const []; + final root = Directory('$home/Library/Developer/Xcode/DerivedData'); + if (!root.existsSync()) return const []; + + final iosDir = '${appRoot.resolveSymbolicLinksSync()}/ios'; + return root.listSync().whereType().where((dir) { + final info = File('${dir.path}/info.plist'); + if (!info.existsSync()) return false; + final match = RegExp(r'WorkspacePath\s*([^<]*)') + .firstMatch(info.readAsStringSync()); + final workspace = match?.group(1); + return workspace != null && workspace.startsWith(iosDir); + }).toList(); +} + +Never _fail(String message) { + stderr.writeln('permission_handler_apple:select: $message'); + exit(1); +} + +const _usage = ''' +Usage: dart run permission_handler_apple:select + + --list Show the flavors declared in $_configName. + --app= App directory (defaults to the current directory). + --derived-data= Custom DerivedData location, matching xcodebuild's + -derivedDataPath. +'''; + +const _exampleConfig = ''' +strict: true +flavors: + dev: + info-plist: ios/Runner/Info-dev.plist + configurations: + - Debug-dev + - Release-dev + prod: + info-plist: ios/Runner/Info-prod.plist + configurations: + - Debug-prod + - Release-prod +'''; diff --git a/permission_handler_apple/example/.gitignore b/permission_handler_apple/example/.gitignore index 2156e772e..260fe82ac 100644 --- a/permission_handler_apple/example/.gitignore +++ b/permission_handler_apple/example/.gitignore @@ -60,6 +60,7 @@ build/ **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ +**/ios/Flutter/ephemeral/ **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* diff --git a/permission_handler_apple/example/README.md b/permission_handler_apple/example/README.md index cca8fca64..acc6dde68 100644 --- a/permission_handler_apple/example/README.md +++ b/permission_handler_apple/example/README.md @@ -14,3 +14,14 @@ A few resources to get you started if this is your first Flutter project: For help getting started with Flutter, view our [online documentation](https://flutter.io/docs), which offers tutorials, samples, guidance on mobile development, and a full API reference. + +## Testing constraints + +Some permissions cannot be tested in all environments: + +| Permission | Constraint | +|---|---| +| `assistant` (Siri) | Requires the `com.apple.developer.siri` entitlement in a provisioned app — **not testable on simulator or without a paid developer account**. Adding `NSSiriUsageDescription` to `Info.plist` without this entitlement will crash the app on launch. | +| `bluetooth` | Requires a **physical device** — Bluetooth is not available on the iOS simulator. | +| `locationAlways` | Requires a physical device and the `NSLocationAlwaysAndWhenInUseUsageDescription` key. | +| `appTrackingTransparency` | Dialog is only shown on iOS 14+ physical devices; always returns `authorized` on the simulator. | diff --git a/permission_handler_apple/example/ios/.gitignore b/permission_handler_apple/example/ios/.gitignore index e96ef602b..da20d3007 100644 --- a/permission_handler_apple/example/ios/.gitignore +++ b/permission_handler_apple/example/ios/.gitignore @@ -30,3 +30,5 @@ Runner/GeneratedPluginRegistrant.* !default.mode2v3 !default.pbxuser !default.perspectivev3 +Flutter/permission_handler.selected +Flutter/permission_handler.resolved.json diff --git a/permission_handler_apple/example/ios/Flutter/AppFrameworkInfo.plist b/permission_handler_apple/example/ios/Flutter/AppFrameworkInfo.plist index 7c5696400..391a902b2 100644 --- a/permission_handler_apple/example/ios/Flutter/AppFrameworkInfo.plist +++ b/permission_handler_apple/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/permission_handler_apple/example/ios/Podfile b/permission_handler_apple/example/ios/Podfile index bdbed18e3..4bcee9ab6 100644 --- a/permission_handler_apple/example/ios/Podfile +++ b/permission_handler_apple/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/permission_handler_apple/example/ios/Runner.xcodeproj/project.pbxproj b/permission_handler_apple/example/ios/Runner.xcodeproj/project.pbxproj index 705be7680..3e63a5493 100644 --- a/permission_handler_apple/example/ios/Runner.xcodeproj/project.pbxproj +++ b/permission_handler_apple/example/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -35,6 +36,9 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = Flutter/ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* permission_handler_apple */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = permission_handler_apple; path = ../../ios/permission_handler_apple; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 862A53EA392D32566500E869 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -55,6 +59,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, B501E7F22BA22C455255CE2E /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -83,6 +88,9 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78DABEA22ED26510000E7860 /* permission_handler_apple */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -133,6 +141,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + PH0FLAV0R0VERIFY0PHASE01 /* Verify permission_handler flavor */, 5CF31A8C72B66ABB365E813D /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, @@ -140,14 +149,15 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - D38B08CB85942E5D11545EE3 /* [CP] Embed Pods Frameworks */, - A7B07F67421488A414C73AAD /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -176,6 +186,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -200,6 +213,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + PH0FLAV0R0VERIFY0PHASE01 /* Verify permission_handler flavor */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Verify permission_handler flavor"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Resolve the plugin, wherever this project gets it from.\nPLUGIN=\"$SRCROOT/Flutter/ephemeral/Packages/.packages/permission_handler_apple/../..\"\n[ -d \"$PLUGIN/tool\" ] || PLUGIN=\"$SRCROOT/.symlinks/plugins/permission_handler_apple\"\n[ -f \"$PLUGIN/tool/verify_flavor_selection.sh\" ] || exit 0\n/bin/sh \"$PLUGIN/tool/verify_flavor_selection.sh\"\n"; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,40 +281,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - A7B07F67421488A414C73AAD /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - D38B08CB85942E5D11545EE3 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -362,7 +356,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -448,7 +442,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -497,7 +491,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -522,7 +516,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_FILE = "Runner/Info-Debug.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -594,6 +588,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/permission_handler_apple/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/permission_handler_apple/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e67b2808a..16b76ec3c 100644 --- a/permission_handler_apple/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/permission_handler_apple/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + version = "1.7"> + + + + + + + + + + - - - - + + @@ -61,8 +80,6 @@ ReferencedContainer = "container:Runner.xcodeproj"> - - Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/permission_handler_apple/example/ios/Runner/Info-Debug.plist b/permission_handler_apple/example/ios/Runner/Info-Debug.plist new file mode 100644 index 000000000..d6b681e5b --- /dev/null +++ b/permission_handler_apple/example/ios/Runner/Info-Debug.plist @@ -0,0 +1,118 @@ + + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppleMusicUsageDescription + Music! + NSBluetoothAlwaysUsageDescription + bluetooth + NSBluetoothPeripheralUsageDescription + bluetooth + NSCalendarsFullAccessUsageDescription + Calendar full access + NSCalendarsUsageDescription + Calendars + NSCalendarsWriteOnlyAccessUsageDescription + Calendar write only + NSCameraUsageDescription + camera + NSLocationAlwaysAndWhenInUseUsageDescription + Always and when in use! + NSLocationAlwaysUsageDescription + Can I have location always? + NSLocationUsageDescription + Older devices need location. + NSLocationWhenInUseUsageDescription + Need location when in use + NSMicrophoneUsageDescription + microphone + NSMotionUsageDescription + motion + NSPhotoLibraryAddUsageDescription + photos add only + NSPhotoLibraryUsageDescription + photos + NSRemindersUsageDescription + reminders +NSSpeechRecognitionUsageDescription + speech + NSUserTrackingUsageDescription + appTrackingTransparency + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + kTCCServiceMediaLibrary + media + + diff --git a/permission_handler_apple/example/ios/Runner/Info.plist b/permission_handler_apple/example/ios/Runner/Info.plist index e31eecab1..56e40159b 100644 --- a/permission_handler_apple/example/ios/Runner/Info.plist +++ b/permission_handler_apple/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -22,6 +24,67 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppleMusicUsageDescription + Music! + NSBluetoothAlwaysUsageDescription + bluetooth + NSBluetoothPeripheralUsageDescription + bluetooth + NSCalendarsFullAccessUsageDescription + Calendar full access + NSCalendarsUsageDescription + Calendars + NSCalendarsWriteOnlyAccessUsageDescription + Calendar write only + NSCameraUsageDescription + camera + NSContactsUsageDescription + contacts + NSLocationAlwaysAndWhenInUseUsageDescription + Always and when in use! + NSLocationAlwaysUsageDescription + Can I have location always? + NSLocationUsageDescription + Older devices need location. + NSLocationWhenInUseUsageDescription + Need location when in use + NSMicrophoneUsageDescription + microphone + NSMotionUsageDescription + motion + NSPhotoLibraryAddUsageDescription + photos add only + NSPhotoLibraryUsageDescription + photos + NSRemindersUsageDescription + reminders +NSSpeechRecognitionUsageDescription + speech + NSUserTrackingUsageDescription + appTrackingTransparency + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,74 +104,7 @@ UIViewControllerBasedStatusBarAppearance - - - NSLocationWhenInUseUsageDescription - Need location when in use - NSLocationAlwaysAndWhenInUseUsageDescription - Always and when in use! - NSLocationUsageDescription - Older devices need location. - NSLocationAlwaysUsageDescription - Can I have location always? - - - NSAppleMusicUsageDescription - Music! - kTCCServiceMediaLibrary - media - - - NSCalendarsUsageDescription - Calendars - NSCalendarsFullAccessUsageDescription - Calendar full access - - - NSCameraUsageDescription - camera - - - NSContactsUsageDescription - contacts - - - NSMicrophoneUsageDescription - microphone - - - NSSpeechRecognitionUsageDescription - speech - - - NSMotionUsageDescription - motion - - - NSPhotoLibraryUsageDescription - photos - - - NSRemindersUsageDescription - reminders - - - NSBluetoothAlwaysUsageDescription - bluetooth - NSBluetoothPeripheralUsageDescription - bluetooth - - - NSUserTrackingUsageDescription - appTrackingTransparency - - - NSSiriUsageDescription - The example app would like access to Siri Kit to demonstrate requesting authorization. - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - + kTCCServiceMediaLibrary + media diff --git a/permission_handler_apple/example/ios/Runner/RunnerDebug.entitlements b/permission_handler_apple/example/ios/Runner/RunnerDebug.entitlements index 21d95c45f..6503a8865 100644 --- a/permission_handler_apple/example/ios/Runner/RunnerDebug.entitlements +++ b/permission_handler_apple/example/ios/Runner/RunnerDebug.entitlements @@ -2,7 +2,10 @@ + diff --git a/permission_handler_apple/example/lib/main.dart b/permission_handler_apple/example/lib/main.dart index 4750f1ac5..ae9563d62 100644 --- a/permission_handler_apple/example/lib/main.dart +++ b/permission_handler_apple/example/lib/main.dart @@ -32,11 +32,11 @@ class PermissionHandlerWidget extends StatefulWidget { } @override - _PermissionHandlerWidgetState createState() => - _PermissionHandlerWidgetState(); + PermissionHandlerWidgetState createState() => PermissionHandlerWidgetState(); } -class _PermissionHandlerWidgetState extends State { +/// The state of the [PermissionHandlerWidget] that listens for permission status changes. +class PermissionHandlerWidgetState extends State { @override Widget build(BuildContext context) { return Center( diff --git a/permission_handler_apple/example/permission_handler.yaml b/permission_handler_apple/example/permission_handler.yaml new file mode 100644 index 000000000..cf1532e9e --- /dev/null +++ b/permission_handler_apple/example/permission_handler.yaml @@ -0,0 +1,17 @@ +# Per-flavor permissions for Swift Package Manager builds. +# +# The two flavors deliberately differ by exactly one key: `debug` +# (Info-Debug.plist) declares no NSContactsUsageDescription, `release` +# (Info.plist) does. CI asserts on that difference to prove a flavor cannot +# inherit another flavor's permissions. +strict: true +flavors: + debug: + info-plist: ios/Runner/Info-Debug.plist + configurations: + - Debug + release: + info-plist: ios/Runner/Info.plist + configurations: + - Release + - Profile diff --git a/permission_handler_apple/example/pubspec.yaml b/permission_handler_apple/example/pubspec.yaml index 81b6f2dc4..042d6ae27 100644 --- a/permission_handler_apple/example/pubspec.yaml +++ b/permission_handler_apple/example/pubspec.yaml @@ -3,25 +3,23 @@ description: Demonstrates how to use the permission_handler_apple plugin. environment: sdk: ^3.7.0 + flutter: ">=3.24.0" dependencies: baseflow_plugin_template: ^2.1.1 + permission_handler_apple: ^9.4.9 + permission_handler_platform_interface: ^4.3.2 flutter: sdk: flutter +resolution: workspace + dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^5.0.0 - permission_handler_apple: - # When depending on this package from a real application you should use: - # permission_handler: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. - path: ../ - - url_launcher: ^6.0.12 + url_launcher: ^6.3.2 flutter: uses-material-design: true diff --git a/permission_handler_apple/ios/.gitignore b/permission_handler_apple/ios/.gitignore index 710ec6cf1..8a62ea716 100644 --- a/permission_handler_apple/ios/.gitignore +++ b/permission_handler_apple/ios/.gitignore @@ -3,34 +3,40 @@ .sconsign.dblite .svn/ -.DS_Store -*.swp -profile - -DerivedData/ -build/ -GeneratedPluginRegistrant.h -GeneratedPluginRegistrant.m - -.generated/ - -*.pbxuser +**/dgph *.mode1v3 *.mode2v3 +*.moved-aside +*.pbxuser *.perspectivev3 - -!default.pbxuser +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. !default.mode1v3 !default.mode2v3 +!default.pbxuser !default.perspectivev3 - -xcuserdata - -*.moved-aside - -*.pyc -*sync/ -Icon? -.tags* - -/Flutter/Generated.xcconfig +# Swift Package Manager +.build/ +*.resolved diff --git a/permission_handler_apple/ios/permission_handler_apple.podspec b/permission_handler_apple/ios/permission_handler_apple.podspec index 81210cb8a..ee0359656 100644 --- a/permission_handler_apple/ios/permission_handler_apple.podspec +++ b/permission_handler_apple/ios/permission_handler_apple.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'permission_handler_apple' - s.version = '9.3.0' + s.version = '9.4.8' s.summary = 'Permission plugin for Flutter.' s.description = <<-DESC Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. @@ -12,12 +12,12 @@ Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Andro s.license = { :file => '../LICENSE' } s.author = { 'Baseflow' => 'hello@baseflow.com' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' + s.source_files = 'permission_handler_apple/Sources/permission_handler_apple/**/*.{h,m}' + s.public_header_files = 'permission_handler_apple/Sources/permission_handler_apple/include/**/*.h' s.dependency 'Flutter' - s.ios.deployment_target = '8.0' + s.ios.deployment_target = '12.0' s.static_framework = true - s.resource_bundles = {'permission_handler_apple_privacy' => ['Resources/PrivacyInfo.xcprivacy']} + s.resource_bundles = {'permission_handler_apple_privacy' => ['permission_handler_apple/Sources/permission_handler_apple/PrivacyInfo.xcprivacy']} end diff --git a/permission_handler_apple/ios/permission_handler_apple/Package.swift b/permission_handler_apple/ios/permission_handler_apple/Package.swift new file mode 100644 index 000000000..6ca1b7527 --- /dev/null +++ b/permission_handler_apple/ios/permission_handler_apple/Package.swift @@ -0,0 +1,747 @@ +// swift-tools-version: 5.9 + +import PackageDescription +import Foundation + +// --------------------------------------------------------------------------- +// Permission configuration +// +// Permissions are resolved in priority order: +// 1. Environment variable (e.g. `launchctl setenv PERMISSION_CAMERA 1` +// or `launchctl setenv PERMISSION_NOTIFICATIONS 0` to explicitly disable) +// 2. Matching key present in the app's Info.plist +// 3. Default: enabled for permissions with no required plist key +// (PERMISSION_NOTIFICATIONS, PERMISSION_CRITICAL_ALERTS), +// disabled for all others. +// +// Additional environment variables: +// PERMISSION_HANDLER_INFO_PLIST ':'-separated Info.plist paths. When set, +// replaces automatic discovery entirely. This +// is the only mechanism that works for builds +// started from Xcode.app (see findAppRoot()). +// PERMISSION_HANDLER_FLAVOR The active flavor, overriding the one +// recorded by the `select` command. +// PERMISSION_HANDLER_CONFIG Path to permission_handler.yaml, for builds +// that cannot locate the app automatically. +// PERMISSION_HANDLER_VERBOSE Set to 1 to log what was discovered and +// which permissions ended up enabled. +// +// After changing Info.plist or env vars, clear DerivedData once so Xcode +// re-evaluates this manifest: +// rm -rf ~/Library/Developer/Xcode/DerivedData +// --------------------------------------------------------------------------- + +let env = ProcessInfo.processInfo.environment +let fileManager = FileManager.default +let verbose = (env["PERMISSION_HANDLER_VERBOSE"] ?? "0") != "0" + +/// Write to stderr. +/// +/// The `swift package` CLI reports this; Xcode discards manifest output +/// entirely, during package resolution and during a build alike. Anything +/// written here is therefore invisible to exactly the users who most need it, +/// which is why a failed lookup also has to degrade safely: every permission +/// reports `denied` rather than crashing (see the disabled strategy +/// implementations in Sources/.../strategies). +/// +/// Never call `fatalError` here: it aborts evaluation of the whole package +/// graph with a message the user cannot act on. +func diagnostic(_ level: String, _ message: String) { + FileHandle.standardError.write("\(level): [permission_handler_apple] \(message)\n".data(using: .utf8)!) +} + +func loadInfoPlist(at url: URL) -> [String: Any]? { + NSDictionary(contentsOf: url) as? [String: Any] +} + +// MARK: - Locating the host app + +/// Directories that never contain the host app's Info.plist, and that are +/// expensive to walk. +let skippedDirectoryNames: Set = [ + "Pods", "build", "DerivedData", "ephemeral", ".dart_tool", ".symlinks", ".git", +] + +/// Depth-first walk of `root`, collecting files whose name satisfies `isMatch`. +/// Package directories such as `Runner.xcodeproj` are descended into, because +/// `project.pbxproj` lives inside one. +func enumerateFiles(under root: URL, matching isMatch: (String) -> Bool) -> [URL] { + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey] + ) else { return [] } + + var matches: [URL] = [] + for case let url as URL in enumerator { + let name = url.lastPathComponent + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? false + + if isDirectory { + if skippedDirectoryNames.contains(name) || name.hasSuffix(".xcworkspace") { + enumerator.skipDescendants() + } + continue + } + + if isMatch(name) { matches.append(url) } + } + return matches +} + +/// True when `dir` is a Flutter *application* root. +/// +/// The `ios/*.xcodeproj` requirement is what separates an app from a plugin +/// package: plugins also have a pubspec.yaml next to an `ios/` directory, but +/// never an Xcode project inside it. Without this check the walk-up below +/// stops on this very package when it is consumed as a path dependency. +func isAppRoot(_ dir: URL) -> Bool { + guard fileManager.fileExists(atPath: dir.appendingPathComponent("pubspec.yaml").path) else { + return false + } + let iosDir = dir.appendingPathComponent("ios") + guard let entries = try? fileManager.contentsOfDirectory(atPath: iosDir.path) else { + return false + } + return entries.contains { $0.hasSuffix(".xcodeproj") } +} + +func walkUpToAppRoot(from start: URL, maxDepth: Int = 12) -> URL? { + var dir = start.standardizedFileURL + for _ in 0.. URL? { + var starts = [URL(fileURLWithPath: fileManager.currentDirectoryPath)] + if let pwd = env["PWD"], !pwd.isEmpty { + starts.append(URL(fileURLWithPath: pwd)) + } + starts.append(URL(fileURLWithPath: #file).deletingLastPathComponent()) + + var visited = Set() + for start in starts { + guard visited.insert(start.resolvingSymlinksInPath().path).inserted else { continue } + if let appRoot = walkUpToAppRoot(from: start) { return appRoot } + } + return nil +} + +// MARK: - Locating Info.plist files + +/// The lookbehind keeps `GENERATE_INFOPLIST_FILE = YES;` from matching. Xcode +/// writes that setting into every target it creates from a template — app +/// extensions especially — and a match there yields a candidate named `YES`, +/// which is enough to suppress the fallback scan below. +let infoPlistSettingRegex = try? NSRegularExpression( + pattern: #"(? = [ + "AppFrameworkInfo.plist", "GoogleService-Info.plist", +] + +func expandBuildSettings(_ value: String, projectDir: URL) -> String { + var expanded = value + for name in ["SRCROOT", "SOURCE_ROOT", "PROJECT_DIR"] { + expanded = expanded + .replacingOccurrences(of: "$(\(name))", with: projectDir.path) + .replacingOccurrences(of: "${\(name)}", with: projectDir.path) + } + return expanded +} + +/// Turn one `INFOPLIST_FILE` value into concrete plist URLs. +/// +/// A value like `Runner/Info-$(CONFIGURATION).plist` cannot be resolved here — +/// the manifest has no idea which configuration is building, and is evaluated +/// once for all of them. Rather than drop it, expand it to every plist in that +/// directory sharing the literal prefix, and let the caller merge them. +func resolveInfoPlistSetting(_ value: String, projectDir: URL) -> [URL] { + let expanded = expandBuildSettings(value, projectDir: projectDir) + let url = expanded.hasPrefix("/") + ? URL(fileURLWithPath: expanded) + : projectDir.appendingPathComponent(expanded) + + guard expanded.contains("$(") || expanded.contains("${") else { return [url] } + + // Only a variable in the file name can be globbed; one in a parent + // directory would need the directory listing of an unknown path. + let directory = url.deletingLastPathComponent() + if directory.path.contains("$(") || directory.path.contains("${") { return [] } + + let name = url.lastPathComponent + guard let variableStart = name.range(of: "$(") ?? name.range(of: "${") else { return [] } + let prefix = String(name[name.startIndex.. [URL] { + guard let regex = infoPlistSettingRegex else { return [] } + let iosDir = appRoot.appendingPathComponent("ios") + + let settingsFiles = enumerateFiles(under: iosDir) { name in + name == "project.pbxproj" || name.hasSuffix(".xcconfig") + } + + var results: [URL] = [] + for file in settingsFiles { + guard let contents = try? String(contentsOf: file, encoding: .utf8) else { continue } + + // SRCROOT is the directory holding the .xcodeproj — `ios/` in a stock + // Flutter app. For a bare xcconfig, `ios/` is the best assumption. + let projectDir = file.pathComponents.contains { $0.hasSuffix(".xcodeproj") } + ? file.deletingLastPathComponent().deletingLastPathComponent() + : iosDir + + let range = NSRange(contents.startIndex..., in: contents) + for match in regex.matches(in: contents, range: range) { + guard match.numberOfRanges > 1, + let valueRange = Range(match.range(at: 1), in: contents) else { continue } + let value = contents[valueRange] + .trimmingCharacters(in: CharacterSet(charactersIn: " \t\"'")) + guard !value.isEmpty else { continue } + results.append(contentsOf: resolveInfoPlistSetting(value, projectDir: projectDir)) + } + } + return results +} + +/// Discard candidates that are not files on disk. +/// +/// `INFOPLIST_FILE` is read from build settings that may name a plist this +/// manifest cannot resolve — a path built from a build variable it does not +/// expand, or a target whose plist Xcode generates at build time. Such a +/// candidate must not count towards "settings produced something", or it +/// suppresses the fallback scan and every permission is compiled out. +func existingFiles(_ urls: [URL]) -> [URL] { + urls.filter { fileManager.fileExists(atPath: $0.path) } +} + +/// Last resort: any plausibly-named plist under `ios/`. Covers projects whose +/// `INFOPLIST_FILE` lives somewhere this manifest does not parse. +func infoPlistsFromScan(appRoot: URL) -> [URL] { + enumerateFiles(under: appRoot.appendingPathComponent("ios")) { name in + name.hasSuffix(".plist") + && name.contains("Info") + && !name.contains("Test") + && !ignoredPlistNames.contains(name) + } +} + +func infoPlistsFromEnvironment() -> [URL]? { + guard let raw = env["PERMISSION_HANDLER_INFO_PLIST"], !raw.isEmpty else { return nil } + return raw + .split(separator: ":") + .map { URL(fileURLWithPath: String($0).trimmingCharacters(in: .whitespaces)) } +} + +/// Every Info.plist that appears to belong to `appRoot`: what the build +/// settings name, falling back to a scan of `ios/` when they name nothing that +/// exists. +func discoverInfoPlists(appRoot: URL) -> [URL] { + let fromSettings = existingFiles(infoPlistsFromBuildSettings(appRoot: appRoot)) + return fromSettings.isEmpty ? infoPlistsFromScan(appRoot: appRoot) : fromSettings +} + +// MARK: - Per-flavor configuration + +/// The user declares flavors in a `permission_handler.yaml` next to the app's +/// pubspec.yaml: +/// +/// ```yaml +/// strict: true +/// flavors: +/// dev: +/// info-plist: ios/Runner/Info-dev.plist +/// configurations: +/// - Debug-dev +/// - Release-dev +/// prod: +/// info-plist: ios/Runner/Info-prod.plist +/// configurations: +/// - Debug-prod +/// - Release-prod +/// ``` +/// +/// This manifest never parses that file. Foundation has no YAML support and a +/// package manifest cannot import a library for its own evaluation, so +/// `dart run permission_handler_apple:select` — the one place with a real YAML +/// parser — translates it into a generated +/// `ios/Flutter/permission_handler.resolved.json`, which is what is read here +/// with JSONSerialization. The YAML file's existence and modification time are +/// the only things consulted directly, to catch a translation that is missing +/// or stale. +/// +/// A flavor names the single Info.plist that defines it. Listing usage +/// description keys directly was considered and rejected: it would duplicate the +/// permission vocabulary across the config, this manifest and the verification +/// build phase, and a drift between those copies fails silently. +/// +/// `configurations` is deliberately not read here. A manifest is given no build +/// settings, so it cannot know which configuration is building and could not act +/// on the mapping; only `tool/verify_flavor_selection.sh`, which runs as a build +/// phase where CONFIGURATION exists, consumes that key. +struct FlavorConfig { + let strict: Bool + let infoPlists: [String: String] // flavor -> path, relative to the app root + let url: URL // the user-facing permission_handler.yaml +} + +/// The user-facing config file and the directory its relative paths resolve +/// against, when this app has one. +/// +/// PERMISSION_HANDLER_CONFIG names the config *and* the root: the file sits +/// next to the app's pubspec.yaml by definition, so its directory is the app. +/// Deriving the root from `appRoot` instead would let the config come from one +/// app while its generated translation and Info.plists come from another. +func locateConfigYaml(appRoot: URL?) -> (yaml: URL, root: URL)? { + let configURL: URL + let root: URL + if let explicit = env["PERMISSION_HANDLER_CONFIG"], !explicit.isEmpty { + configURL = URL(fileURLWithPath: explicit).standardizedFileURL + root = configURL.deletingLastPathComponent() + } else if let appRoot { + configURL = appRoot.appendingPathComponent("permission_handler.yaml") + root = appRoot + } else { + return nil + } + return fileManager.fileExists(atPath: configURL.path) ? (configURL, root) : nil +} + +func modificationDate(of url: URL) -> Date? { + (try? fileManager.attributesOfItem(atPath: url.path))?[.modificationDate] as? Date +} + +/// Load the generated translation of `yaml`, refusing anything missing, stale +/// or malformed. +/// +/// Every failure returns nil after a diagnostic, and the caller compiles no +/// permissions in. Falling back to merged discovery instead would hand the +/// build the union of every flavor's permissions — the exact leak a config +/// file exists to prevent — so a broken translation must never be "ignored". +func loadFlavorConfig(yaml: URL, configRoot: URL) -> FlavorConfig? { + let resolved = configRoot.appendingPathComponent("ios/Flutter/permission_handler.resolved.json") + let rerun = """ + Run `dart run permission_handler_apple:select ` to regenerate it, then build again. + """ + + guard fileManager.fileExists(atPath: resolved.path) else { + diagnostic("error", """ + \(yaml.lastPathComponent) is present but its generated translation \ + (\(resolved.path)) is not, so every iOS permission has been compiled out. \(rerun) + """) + return nil + } + + // Both files exist, so unreadable timestamps mean something is wrong with + // the filesystem rather than with the config. Refuse either way: skipping + // the check would let a stale translation through silently, which is the + // one outcome this guard exists to prevent. + guard let yamlDate = modificationDate(of: yaml), + let resolvedDate = modificationDate(of: resolved) else { + diagnostic("error", """ + The modification time of \(yaml.lastPathComponent) or \ + \(resolved.lastPathComponent) could not be read, so it is not possible to tell \ + whether the generated translation is current. Every iOS permission has been \ + compiled out. \(rerun) + """) + return nil + } + + if yamlDate > resolvedDate { + diagnostic("error", """ + \(yaml.lastPathComponent) was modified after its generated translation \ + (\(resolved.lastPathComponent)), so every iOS permission has been compiled out \ + rather than building with a stale configuration. \(rerun) + """) + return nil + } + + guard let data = try? Data(contentsOf: resolved), + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let flavors = root["flavors"] as? [String: Any], !flavors.isEmpty else { + diagnostic("error", """ + \(resolved.path) is not a valid generated configuration, so every iOS permission \ + has been compiled out. \(rerun) + """) + return nil + } + + var infoPlists: [String: String] = [:] + for (flavor, raw) in flavors { + guard let entry = raw as? [String: Any], + let plist = entry["infoPlist"] as? String, !plist.isEmpty else { continue } + infoPlists[flavor] = plist + } + + guard !infoPlists.isEmpty else { + diagnostic("error", """ + \(resolved.path) declares no usable flavors, so every iOS permission has been \ + compiled out. \(rerun) + """) + return nil + } + + return FlavorConfig( + strict: root["strict"] as? Bool ?? true, + infoPlists: infoPlists, + url: yaml + ) +} + +/// The flavor this build should compile permissions for. +/// +/// Xcode exposes no build settings to manifest evaluation, so `CONFIGURATION` +/// cannot be read here and the selection has to be made out of band — by +/// `dart run permission_handler_apple:select `, which records it and +/// clears the caches that would otherwise keep serving the previous answer. +func resolveFlavor(appRoot: URL?) -> String? { + if let fromEnv = env["PERMISSION_HANDLER_FLAVOR"], !fromEnv.isEmpty { return fromEnv } + guard let appRoot else { return nil } + let selection = appRoot.appendingPathComponent("ios/Flutter/permission_handler.selected") + guard let raw = try? String(contentsOf: selection, encoding: .utf8) else { return nil } + let flavor = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return flavor.isEmpty ? nil : flavor +} + +/// Collect the usage description keys that apply to this build. +/// +/// With a `permission_handler.yaml` the active flavor selects exactly one +/// Info.plist and nothing is merged, so a permission declared only by `dev` +/// never reaches a `prod` binary. Without one the keys of every discovered +/// Info.plist are merged, which errs towards enabling a permission. +func findInfoPlist() -> [String: Any] { + var candidates: [URL] + let appRoot = findAppRoot() + + if let explicit = infoPlistsFromEnvironment() { + candidates = explicit + } else if let located = locateConfigYaml(appRoot: appRoot) { + let configYaml = located.yaml + let configRoot = located.root + + guard let config = loadFlavorConfig(yaml: configYaml, configRoot: configRoot) else { + // Diagnostics already emitted; a present-but-unusable config + // compiles nothing in rather than falling back to the merge. + return [:] + } + + guard let flavor = resolveFlavor(appRoot: configRoot) else { + guard !config.strict else { + diagnostic("error", """ + \(config.url.lastPathComponent) declares the flavors [\(known(config))] with \ + "strict": true, but the active flavor could not be determined, so every iOS \ + permission has been compiled out. Run \ + `dart run permission_handler_apple:select ` before building, or set \ + PERMISSION_HANDLER_FLAVOR. + """) + return [:] + } + diagnostic("warning", """ + \(config.url.lastPathComponent) declares the flavors [\(known(config))] but the \ + active flavor could not be determined. Falling back to merging every Info.plist \ + found, which enables the union of all flavors' permissions. Set "strict": true to \ + turn this into an error instead. + """) + return mergeInfoPlists(discoverInfoPlists(appRoot: configRoot), warnOnDivergence: true) + } + + guard let relative = config.infoPlists[flavor] else { + diagnostic("error", """ + Flavor "\(flavor)" is not declared in \(config.url.lastPathComponent) \ + (known flavors: \(known(config))). Every iOS permission has been compiled out. + """) + return [:] + } + + let plist = configRoot.appendingPathComponent(relative) + guard fileManager.fileExists(atPath: plist.path) else { + // Falling back to discovery here would hand this flavor the union of + // every other flavor's permissions, which is the leak the config + // exists to prevent. Compile nothing in and say why. + diagnostic("error", """ + Flavor "\(flavor)" points at \(relative), which does not exist \ + (\(plist.path)). Every iOS permission has been compiled out. Fix the "info-plist" \ + path in \(config.url.lastPathComponent) and re-run \ + `dart run permission_handler_apple:select \(flavor)`. + """) + return [:] + } + + if verbose { diagnostic("note", "active flavor: \(flavor) (\(relative))") } + // One flavor, one plist: never merge, so nothing can leak between flavors. + return mergeInfoPlists([plist], warnOnDivergence: false) + } else if let appRoot { + if verbose { diagnostic("note", "app root: \(appRoot.path)") } + candidates = discoverInfoPlists(appRoot: appRoot) + } else { + diagnostic("warning", """ + Could not locate the host app, so every iOS permission has been compiled out and \ + permission checks will report `denied`. Automatic detection needs the build to be \ + started from the Flutter project directory, which is not the case for builds run \ + directly from Xcode.app. Point this manifest at the app's Info.plist with \ + `launchctl setenv PERMISSION_HANDLER_INFO_PLIST /path/to/ios/Runner/Info.plist`, or \ + enable permissions individually with `launchctl setenv PERMISSION_CAMERA 1`, then \ + run `rm -rf ~/Library/Developer/Xcode/DerivedData` so this manifest is re-evaluated. + """) + return [:] + } + + return mergeInfoPlists(candidates, warnOnDivergence: true) +} + +func known(_ config: FlavorConfig) -> String { + config.infoPlists.keys.sorted().joined(separator: ", ") +} + +/// Read every candidate plist and union their keys. +/// +/// Only key *presence* matters to `enabled()`, so the first value for a key +/// wins. `warnOnDivergence` is off when a flavor picked a single plist, where +/// there is nothing to diverge. +func mergeInfoPlists(_ candidates: [URL], warnOnDivergence: Bool) -> [String: Any] { + var seen = Set() + let unique = candidates.filter { seen.insert($0.standardizedFileURL.path).inserted } + + var merged: [String: Any] = [:] + var loaded: [URL] = [] + var keysPerPlist: [Set] = [] + + for url in unique { + guard let plist = loadInfoPlist(at: url) else { continue } + loaded.append(url) + keysPerPlist.append(Set(plist.keys.filter { $0.hasSuffix("UsageDescription") })) + for (key, value) in plist where merged[key] == nil { merged[key] = value } + } + + if loaded.isEmpty { + diagnostic("warning", """ + No readable Info.plist was found for the host app, so every iOS permission has been \ + compiled out and permission checks will report `denied`. Looked at: \ + \(unique.map(\.path).joined(separator: ", ")). Set PERMISSION_HANDLER_INFO_PLIST to \ + the path of your Info.plist, then run `rm -rf ~/Library/Developer/Xcode/DerivedData`. + """) + return [:] + } + + if verbose { + diagnostic("note", "Info.plist files used:\n " + loaded.map(\.path).joined(separator: "\n ")) + } + + // Merging across configurations enables the union of their permissions. Say + // so when they actually disagree, because the extra permissions end up in + // the release binary and can trigger App Store rejection (ITMS-90683). + if warnOnDivergence, let first = keysPerPlist.first, + keysPerPlist.contains(where: { $0 != first }) { + let divergent = keysPerPlist.reduce(into: Set()) { $0.formUnion($1) } + .subtracting(keysPerPlist.reduce(into: keysPerPlist[0]) { $0.formIntersection($1) }) + diagnostic("warning", """ + The discovered Info.plist files declare different usage descriptions \ + (\(divergent.sorted().joined(separator: ", "))). Every permission found in any of \ + them is enabled for all build configurations, because a Swift package manifest \ + cannot vary its settings per configuration. Declare a permission_handler.json with \ + one flavor per configuration to compile each of them separately. + """) + } + + return merged +} + +let infoPlist = findInfoPlist() + +/// Every macro this manifest resolved, for PERMISSION_HANDLER_VERBOSE. +var resolvedMacros: [String: String] = [:] + +/// Return "1" if the env var is set (non-zero), "0" if explicitly set to "0", +/// else "1" if any Info.plist key is present, else `defaultValue`. +/// +/// An empty value counts as unset: `launchctl setenv PERMISSION_CAMERA ""` is +/// how a variable gets cleared, and reading that as "enabled" would be the +/// opposite of what was asked. +func enabled(_ envKey: String, plistKeys: String..., defaultValue: String = "0") -> String { + let value: String + if let val = env[envKey]?.trimmingCharacters(in: .whitespaces), !val.isEmpty { + value = val == "0" ? "0" : "1" + } else if plistKeys.contains(where: { infoPlist[$0] != nil }) { + value = "1" + } else { + value = defaultValue + } + resolvedMacros[envKey] = value + return value +} + +let permissionDefines: [CSetting] = [ + // dart: PermissionGroup.calendar (< iOS 17) + .define("PERMISSION_EVENTS", + to: enabled("PERMISSION_EVENTS", + plistKeys: "NSCalendarsUsageDescription")), + // dart: PermissionGroup.calendarFullAccess (iOS 17+) / PermissionGroup.calendarWriteOnly (iOS 17+) + .define("PERMISSION_EVENTS_FULL_ACCESS", + to: enabled("PERMISSION_EVENTS_FULL_ACCESS", + plistKeys: "NSCalendarsFullAccessUsageDescription", + "NSCalendarsWriteOnlyAccessUsageDescription")), + // dart: PermissionGroup.reminders + .define("PERMISSION_REMINDERS", + to: enabled("PERMISSION_REMINDERS", + plistKeys: "NSRemindersUsageDescription")), + // dart: PermissionGroup.contacts + .define("PERMISSION_CONTACTS", + to: enabled("PERMISSION_CONTACTS", + plistKeys: "NSContactsUsageDescription")), + // dart: PermissionGroup.camera + .define("PERMISSION_CAMERA", + to: enabled("PERMISSION_CAMERA", + plistKeys: "NSCameraUsageDescription")), + // dart: PermissionGroup.microphone + .define("PERMISSION_MICROPHONE", + to: enabled("PERMISSION_MICROPHONE", + plistKeys: "NSMicrophoneUsageDescription")), + // dart: PermissionGroup.speech + .define("PERMISSION_SPEECH_RECOGNIZER", + to: enabled("PERMISSION_SPEECH_RECOGNIZER", + plistKeys: "NSSpeechRecognitionUsageDescription")), + // dart: PermissionGroup.photos / PermissionGroup.photosAddOnly + // NSPhotoLibraryAddUsageDescription alone also enables PhotoPermissionStrategy because the + // native code compiles photosAddOnly support under PERMISSION_PHOTOS. + .define("PERMISSION_PHOTOS", + to: enabled("PERMISSION_PHOTOS", + plistKeys: "NSPhotoLibraryUsageDescription", + "NSPhotoLibraryAddUsageDescription")), + // dart: PermissionGroup.photosAddOnly + .define("PERMISSION_PHOTOS_ADD_ONLY", + to: enabled("PERMISSION_PHOTOS_ADD_ONLY", + plistKeys: "NSPhotoLibraryAddUsageDescription")), + // dart: PermissionGroup.location / locationAlways / locationWhenInUse + .define("PERMISSION_LOCATION", + to: enabled("PERMISSION_LOCATION", + plistKeys: "NSLocationWhenInUseUsageDescription", + "NSLocationAlwaysAndWhenInUseUsageDescription")), + // dart: PermissionGroup.locationWhenInUse (only when locationAlways is NOT needed) + .define("PERMISSION_LOCATION_WHENINUSE", + to: enabled("PERMISSION_LOCATION_WHENINUSE", + plistKeys: "NSLocationWhenInUseUsageDescription")), + // dart: PermissionGroup.locationAlways + .define("PERMISSION_LOCATION_ALWAYS", + to: enabled("PERMISSION_LOCATION_ALWAYS", + plistKeys: "NSLocationAlwaysAndWhenInUseUsageDescription")), + // dart: PermissionGroup.notification (no required Info.plist key — enabled by default) + .define("PERMISSION_NOTIFICATIONS", + to: enabled("PERMISSION_NOTIFICATIONS", defaultValue: "1")), + // dart: PermissionGroup.mediaLibrary + .define("PERMISSION_MEDIA_LIBRARY", + to: enabled("PERMISSION_MEDIA_LIBRARY", + plistKeys: "NSAppleMusicUsageDescription")), + // dart: PermissionGroup.sensors + .define("PERMISSION_SENSORS", + to: enabled("PERMISSION_SENSORS", + plistKeys: "NSMotionUsageDescription")), + // dart: PermissionGroup.bluetooth + .define("PERMISSION_BLUETOOTH", + to: enabled("PERMISSION_BLUETOOTH", + plistKeys: "NSBluetoothAlwaysUsageDescription", + "NSBluetoothPeripheralUsageDescription")), + // dart: PermissionGroup.appTrackingTransparency + .define("PERMISSION_APP_TRACKING_TRANSPARENCY", + to: enabled("PERMISSION_APP_TRACKING_TRANSPARENCY", + plistKeys: "NSUserTrackingUsageDescription")), + // dart: PermissionGroup.criticalAlerts (no required Info.plist key — requires Apple entitlement, + // opt-in via env var: launchctl setenv PERMISSION_CRITICAL_ALERTS 1) + .define("PERMISSION_CRITICAL_ALERTS", + to: enabled("PERMISSION_CRITICAL_ALERTS")), + // dart: PermissionGroup.assistant + .define("PERMISSION_ASSISTANT", + to: enabled("PERMISSION_ASSISTANT", + plistKeys: "NSSiriUsageDescription")), +] + +if verbose { + diagnostic("note", "resolved permission macros:\n " + + resolvedMacros.sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: "\n ")) +} + +let package = Package( + name: "permission_handler_apple", + platforms: [ + .iOS("12.0"), + ], + products: [ + .library(name: "permission-handler-apple", targets: ["permission_handler_apple"]), + ], + dependencies: [], + targets: [ + .target( + name: "permission_handler_apple", + dependencies: [], + path: "Sources/permission_handler_apple", + resources: [ + .process("PrivacyInfo.xcprivacy"), + ], + publicHeadersPath: "include", + cSettings: [ + .headerSearchPath("."), + .headerSearchPath("strategies"), + .headerSearchPath("util"), + .headerSearchPath("include/permission_handler_apple"), + ] + permissionDefines + ), + ] +) diff --git a/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionHandlerEnums.h similarity index 98% rename from permission_handler_apple/ios/Classes/PermissionHandlerEnums.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionHandlerEnums.h index c153dc500..69cf6620c 100644 --- a/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionHandlerEnums.h @@ -163,7 +163,8 @@ typedef NS_ENUM(int, PermissionGroup) { PermissionGroupCalendarWriteOnly, PermissionGroupCalendarFullAccess, PermissionGroupAssistant, - PermissionGroupBackgroundRefresh + PermissionGroupBackgroundRefresh, + PermissionGroupAccessLocalNetwork }; typedef NS_ENUM(int, PermissionStatus) { diff --git a/permission_handler_apple/ios/Classes/PermissionHandlerPlugin.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionHandlerPlugin.h similarity index 100% rename from permission_handler_apple/ios/Classes/PermissionHandlerPlugin.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionHandlerPlugin.h diff --git a/permission_handler_apple/ios/Classes/PermissionHandlerPlugin.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionHandlerPlugin.m similarity index 100% rename from permission_handler_apple/ios/Classes/PermissionHandlerPlugin.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionHandlerPlugin.m diff --git a/permission_handler_apple/ios/Classes/PermissionManager.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionManager.h similarity index 100% rename from permission_handler_apple/ios/Classes/PermissionManager.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionManager.h diff --git a/permission_handler_apple/ios/Classes/PermissionManager.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionManager.m similarity index 100% rename from permission_handler_apple/ios/Classes/PermissionManager.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PermissionManager.m diff --git a/permission_handler_apple/ios/Resources/PrivacyInfo.xcprivacy b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PrivacyInfo.xcprivacy similarity index 100% rename from permission_handler_apple/ios/Resources/PrivacyInfo.xcprivacy rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/PrivacyInfo.xcprivacy diff --git a/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/include/permission_handler_apple/PermissionHandlerPlugin.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/include/permission_handler_apple/PermissionHandlerPlugin.h new file mode 100644 index 000000000..2d3a848d8 --- /dev/null +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/include/permission_handler_apple/PermissionHandlerPlugin.h @@ -0,0 +1,7 @@ +#import + +@class PermissionManager; + +@interface PermissionHandlerPlugin : NSObject +- (instancetype)initWithPermissionManager:(PermissionManager *)permissionManager; +@end diff --git a/permission_handler_apple/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AppTrackingTransparencyPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AppTrackingTransparencyPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AppTrackingTransparencyPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/AppTrackingTransparencyPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AppTrackingTransparencyPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/AssistantPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AssistantPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/AssistantPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AssistantPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/AssistantPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AssistantPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/AssistantPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AssistantPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/AudioVideoPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AudioVideoPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/AudioVideoPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AudioVideoPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/AudioVideoPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AudioVideoPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/AudioVideoPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/AudioVideoPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/BackgroundRefreshStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BackgroundRefreshStrategy.h similarity index 92% rename from permission_handler_apple/ios/Classes/strategies/BackgroundRefreshStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BackgroundRefreshStrategy.h index 2dfe0032c..2f6797453 100644 --- a/permission_handler_apple/ios/Classes/strategies/BackgroundRefreshStrategy.h +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BackgroundRefreshStrategy.h @@ -6,6 +6,7 @@ // #import +#import #import "PermissionStrategy.h" NS_ASSUME_NONNULL_BEGIN diff --git a/permission_handler_apple/ios/Classes/strategies/BackgroundRefreshStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BackgroundRefreshStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/BackgroundRefreshStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BackgroundRefreshStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/BluetoothPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BluetoothPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/BluetoothPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BluetoothPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/BluetoothPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BluetoothPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/BluetoothPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/BluetoothPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/ContactPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/ContactPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/ContactPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/ContactPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/ContactPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/ContactPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/ContactPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/ContactPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/CriticalAlertsPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/CriticalAlertsPermissionStrategy.h similarity index 95% rename from permission_handler_apple/ios/Classes/strategies/CriticalAlertsPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/CriticalAlertsPermissionStrategy.h index 44b857f48..fbc332ccd 100644 --- a/permission_handler_apple/ios/Classes/strategies/CriticalAlertsPermissionStrategy.h +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/CriticalAlertsPermissionStrategy.h @@ -10,6 +10,7 @@ #if PERMISSION_CRITICAL_ALERTS +#import #import @interface CriticalAlertsPermissionStrategy : NSObject diff --git a/permission_handler_apple/ios/Classes/strategies/CriticalAlertsPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/CriticalAlertsPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/CriticalAlertsPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/CriticalAlertsPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/EventPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/EventPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/EventPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/EventPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/EventPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/EventPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/EventPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/EventPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/LocationPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/LocationPermissionStrategy.h similarity index 96% rename from permission_handler_apple/ios/Classes/strategies/LocationPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/LocationPermissionStrategy.h index 52bcc89dc..082e9d5b4 100644 --- a/permission_handler_apple/ios/Classes/strategies/LocationPermissionStrategy.h +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/LocationPermissionStrategy.h @@ -8,6 +8,7 @@ #if PERMISSION_LOCATION || PERMISSION_LOCATION_WHENINUSE || PERMISSION_LOCATION_ALWAYS +#import #import @interface LocationPermissionStrategy : NSObject diff --git a/permission_handler_apple/ios/Classes/strategies/LocationPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/LocationPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/LocationPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/LocationPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/MediaLibraryPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/MediaLibraryPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/MediaLibraryPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/MediaLibraryPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/MediaLibraryPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/MediaLibraryPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/MediaLibraryPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/MediaLibraryPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/NotificationPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/NotificationPermissionStrategy.h similarity index 95% rename from permission_handler_apple/ios/Classes/strategies/NotificationPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/NotificationPermissionStrategy.h index df5a795d6..73059a4b5 100644 --- a/permission_handler_apple/ios/Classes/strategies/NotificationPermissionStrategy.h +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/NotificationPermissionStrategy.h @@ -10,6 +10,7 @@ #if PERMISSION_NOTIFICATIONS +#import #import @interface NotificationPermissionStrategy : NSObject diff --git a/permission_handler_apple/ios/Classes/strategies/NotificationPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/NotificationPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/NotificationPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/NotificationPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/PermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/PermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/PhonePermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhonePermissionStrategy.h similarity index 92% rename from permission_handler_apple/ios/Classes/strategies/PhonePermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhonePermissionStrategy.h index a90cfc81f..c36d999b1 100644 --- a/permission_handler_apple/ios/Classes/strategies/PhonePermissionStrategy.h +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhonePermissionStrategy.h @@ -6,6 +6,7 @@ // #import +#import #import "PermissionStrategy.h" NS_ASSUME_NONNULL_BEGIN diff --git a/permission_handler_apple/ios/Classes/strategies/PhonePermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhonePermissionStrategy.m similarity index 78% rename from permission_handler_apple/ios/Classes/strategies/PhonePermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhonePermissionStrategy.m index 37222849d..6944a9835 100644 --- a/permission_handler_apple/ios/Classes/strategies/PhonePermissionStrategy.m +++ b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhonePermissionStrategy.m @@ -17,8 +17,9 @@ - (PermissionStatus)checkPermissionStatus:(PermissionGroup)permission { } - (void)checkServiceStatus:(PermissionGroup)permission completionHandler:(ServiceStatusHandler)completionHandler { - // https://stackoverflow.com/a/5095058 - if (![[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:@"tel://"]]) { + UIApplication *app = [UIApplication sharedApplication]; + NSURL *telURL = [NSURL URLWithString:@"tel://"]; + if (![app canOpenURL:telURL]) { completionHandler(ServiceStatusNotApplicable); } completionHandler([self canDevicePlaceAPhoneCall] ? ServiceStatusEnabled : ServiceStatusDisabled); @@ -52,15 +53,14 @@ -(bool) canDevicePlaceAPhoneCall { } -(bool)canPlacePhoneCallWithCarrier:(CTCarrier *)carrier { - // https://stackoverflow.com/a/11595365 - NSString *mnc = [carrier mobileNetworkCode]; - if (([mnc length] == 0) || ([mnc isEqualToString:@"65535"])) { - // Device cannot place a call at this time. SIM might be removed. + NSString *networkCode = [carrier mobileNetworkCode]; + if (networkCode.length == 0 || [networkCode isEqualToString:@"65535"]) { + // Device is unable to initiate a call at this time. SIM might be missing. return NO; - } else { - // Device can place a phone call - return YES; } + + // Mobile Network Code is valid and device can initiate a call + return YES; } @end diff --git a/permission_handler_apple/ios/Classes/strategies/PhotoPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhotoPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/PhotoPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhotoPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/PhotoPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhotoPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/PhotoPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/PhotoPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/SensorPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SensorPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/SensorPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SensorPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/SensorPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SensorPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/SensorPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SensorPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/SpeechPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SpeechPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/SpeechPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SpeechPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/SpeechPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SpeechPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/SpeechPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/SpeechPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/StoragePermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/StoragePermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/StoragePermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/StoragePermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/StoragePermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/StoragePermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/StoragePermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/StoragePermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/strategies/UnknownPermissionStrategy.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/UnknownPermissionStrategy.h similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/UnknownPermissionStrategy.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/UnknownPermissionStrategy.h diff --git a/permission_handler_apple/ios/Classes/strategies/UnknownPermissionStrategy.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/UnknownPermissionStrategy.m similarity index 100% rename from permission_handler_apple/ios/Classes/strategies/UnknownPermissionStrategy.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/strategies/UnknownPermissionStrategy.m diff --git a/permission_handler_apple/ios/Classes/util/Codec.h b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/util/Codec.h similarity index 100% rename from permission_handler_apple/ios/Classes/util/Codec.h rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/util/Codec.h diff --git a/permission_handler_apple/ios/Classes/util/Codec.m b/permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/util/Codec.m similarity index 100% rename from permission_handler_apple/ios/Classes/util/Codec.m rename to permission_handler_apple/ios/permission_handler_apple/Sources/permission_handler_apple/util/Codec.m diff --git a/permission_handler_apple/pubspec.yaml b/permission_handler_apple/pubspec.yaml index 4abd13fb3..6bacce0b4 100644 --- a/permission_handler_apple/pubspec.yaml +++ b/permission_handler_apple/pubspec.yaml @@ -2,11 +2,12 @@ name: permission_handler_apple description: Permission plugin for Flutter. This plugin provides the iOS 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: 9.4.7 +version: 9.6.1 environment: - sdk: ">=2.18.0 <4.0.0" - flutter: ">=3.3.0" + sdk: ^3.6.0 + flutter: ">=3.24.0" +resolution: workspace flutter: plugin: @@ -18,8 +19,11 @@ flutter: dependencies: flutter: sdk: flutter - permission_handler_platform_interface: ^4.2.0 + permission_handler_platform_interface: ^4.4.0 + # Used only by `bin/select.dart` to read permission_handler.yaml; pure Dart, + # tree-shaken out of app binaries. + yaml: ^3.1.0 dev_dependencies: - flutter_lints: ^1.0.4 + flutter_lints: ^5.0.0 plugin_platform_interface: ^2.0.0 diff --git a/permission_handler_apple/tool/verify_flavor_selection.sh b/permission_handler_apple/tool/verify_flavor_selection.sh new file mode 100755 index 000000000..55df4c4d0 --- /dev/null +++ b/permission_handler_apple/tool/verify_flavor_selection.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# +# Fails the build when the permissions compiled into the Swift package do not +# belong to the configuration being built. +# +# A Swift package manifest is evaluated once, is cached, and is given none of +# Xcode's build settings, so it cannot tell which configuration is running and +# cannot notice that its answer went stale. This script runs as a build phase of +# the app target, where CONFIGURATION *is* available, and compares it against the +# flavor recorded by `dart run permission_handler_apple:select`. +# +# The user-facing config is permission_handler.yaml, but this script reads the +# generated permission_handler.resolved.json that `select` derives from it — +# `yaml` is not in the Python standard library, and JSON is. The YAML file +# itself is consulted only for existence and modification time, to catch a +# translation that is missing or stale. +# +# Add it as a "Run Script" build phase on the Runner target, as early in the +# phase list as possible so a mismatch fails before the app is compiled. It is a +# no-op for projects without a permission_handler.yaml and for CocoaPods builds. + +set -eu + +APP_ROOT="${SRCROOT}/.." +CONFIG="${APP_ROOT}/permission_handler.yaml" +RESOLVED="${APP_ROOT}/ios/Flutter/permission_handler.resolved.json" +SELECTION="${APP_ROOT}/ios/Flutter/permission_handler.selected" +SPM_PACKAGE="${SRCROOT}/Flutter/ephemeral/Packages/.packages/permission_handler_apple" + +# Per-flavor permissions are opt-in; nothing to check without a config. +[ -f "${CONFIG}" ] || exit 0 + +# Only Swift Package Manager builds resolve permissions from Package.swift. +# Under CocoaPods the PERMISSION_* macros come from the Podfile's +# GCC_PREPROCESSOR_DEFINITIONS, which this script has no say over, so a flavor +# selection means nothing and must not fail the build. +[ -d "${SPM_PACKAGE}" ] || exit 0 + +if [ ! -f "${RESOLVED}" ]; then + echo "error: [permission_handler_apple] ${CONFIG} is present but its generated translation (${RESOLVED}) is not, so no permissions were compiled in. Run: dart run permission_handler_apple:select " + exit 1 +fi + +# `find -newer` instead of `[ -nt ]`: -nt is a bash extension and this script +# declares /bin/sh. Errors are not swallowed — a comparison that could not run +# is reported rather than read as "not stale", which would let a stale +# translation build silently. +if ! STALE=$(find "${CONFIG}" -newer "${RESOLVED}" 2>&1); then + echo "error: [permission_handler_apple] could not compare ${CONFIG} against its generated translation: ${STALE}" + exit 1 +fi + +if [ -n "${STALE}" ]; then + echo "error: [permission_handler_apple] ${CONFIG} was modified after its generated translation, so this build would use a stale permission configuration. Run: dart run permission_handler_apple:select " + exit 1 +fi + +if [ ! -x /usr/bin/python3 ]; then + echo "warning: [permission_handler_apple] /usr/bin/python3 not found, skipping flavor verification." + exit 0 +fi + +EXPECTED=$(/usr/bin/python3 - "${RESOLVED}" "${CONFIGURATION}" <<'PY' +import json, sys + +resolved_path, configuration = sys.argv[1], sys.argv[2] +try: + with open(resolved_path) as handle: + flavors = json.load(handle).get("flavors", {}) +except (OSError, ValueError) as error: + print(f"!invalid:{error}") + sys.exit(0) + +for name, entry in flavors.items(): + if configuration in (entry or {}).get("configurations", []): + print(name) + break +PY +) + +case "${EXPECTED}" in + '!invalid:'*) + echo "error: [permission_handler_apple] ${RESOLVED} could not be read: ${EXPECTED#!invalid:}. Run: dart run permission_handler_apple:select " + exit 1 + ;; + '') + echo "warning: [permission_handler_apple] no flavor in ${CONFIG} lists the \"${CONFIGURATION}\" configuration, so the compiled permissions cannot be verified. Add it to the flavor's \"configurations\" list." + exit 0 + ;; +esac + +if [ ! -f "${SELECTION}" ]; then + echo "error: [permission_handler_apple] building \"${CONFIGURATION}\" needs the \"${EXPECTED}\" permission flavor, but no flavor has been selected. Run: dart run permission_handler_apple:select ${EXPECTED}" + exit 1 +fi + +SELECTED=$(tr -d '[:space:]' < "${SELECTION}") + +if [ "${SELECTED}" != "${EXPECTED}" ]; then + echo "error: [permission_handler_apple] building \"${CONFIGURATION}\" needs the \"${EXPECTED}\" permission flavor, but \"${SELECTED}\" is selected, so this build would ship ${SELECTED}'s permissions. Run: dart run permission_handler_apple:select ${EXPECTED}" + exit 1 +fi diff --git a/permission_handler_html/CHANGELOG.md b/permission_handler_html/CHANGELOG.md index bfe470242..145f6018d 100644 --- a/permission_handler_html/CHANGELOG.md +++ b/permission_handler_html/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.1.4+1 + +* Replaces runtime-unsafe .isA() calls with Dart is web.DOMException checks to fix web release build failures. + +## 0.1.4+0 + +* Adds support for the new Android 17 permission `ACCESS_LOCAL_NETWORK` + ## 0.1.3+5 - Updates the way how `window.navigator.mediaDevices` is accessed to keep supporting WASM. diff --git a/permission_handler_html/example/lib/main.dart b/permission_handler_html/example/lib/main.dart index e3650eba8..3cc3d0b5b 100644 --- a/permission_handler_html/example/lib/main.dart +++ b/permission_handler_html/example/lib/main.dart @@ -3,29 +3,33 @@ import 'package:flutter/material.dart'; import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart'; void main() { - runApp(BaseflowPluginExample( + runApp( + BaseflowPluginExample( pluginName: 'Permission Handler', githubURL: 'https://github.com/Baseflow/flutter-permission-handler', pubDevURL: 'https://pub.dev/packages/permission_handler', - pages: [PermissionHandlerWidget.createPage()])); + pages: [PermissionHandlerWidget.createPage()], + ), + ); } ///Defines the main theme color final MaterialColor themeMaterialColor = BaseflowPluginExample.createMaterialColor( - const Color.fromRGBO(48, 49, 60, 1)); + const Color.fromRGBO(48, 49, 60, 1), +); /// A Flutter application demonstrating the functionality of this plugin class PermissionHandlerWidget extends StatefulWidget { /// Creates a [PermissionHandlerWidget]. - const PermissionHandlerWidget({ - super.key, - }); + const PermissionHandlerWidget({super.key}); /// Create a page containing the functionality of this plugin static ExamplePage createPage() { return ExamplePage( - Icons.location_on, (context) => const PermissionHandlerWidget()); + Icons.location_on, + (context) => const PermissionHandlerWidget(), + ); } @override @@ -38,18 +42,19 @@ class _PermissionHandlerWidgetState extends State { Widget build(BuildContext context) { return Center( child: ListView( - children: Permission.values - .where((permission) { - return permission != Permission.unknown && - permission != Permission.mediaLibrary && - permission != Permission.photos && - permission != Permission.photosAddOnly && - permission != Permission.reminders && - permission != Permission.appTrackingTransparency && - permission != Permission.criticalAlerts; - }) - .map((permission) => PermissionWidget(permission)) - .toList()), + children: Permission.values + .where((permission) { + return permission != Permission.unknown && + permission != Permission.mediaLibrary && + permission != Permission.photos && + permission != Permission.photosAddOnly && + permission != Permission.reminders && + permission != Permission.appTrackingTransparency && + permission != Permission.criticalAlerts; + }) + .map((permission) => PermissionWidget(permission)) + .toList(), + ), ); } } @@ -57,10 +62,7 @@ class _PermissionHandlerWidgetState extends State { /// Permission widget containing information about the passed [Permission] class PermissionWidget extends StatefulWidget { /// Constructs a [PermissionWidget] for the supplied [Permission] - const PermissionWidget( - this._permission, { - super.key, - }); + const PermissionWidget(this._permission, {super.key}); final Permission _permission; @@ -84,8 +86,9 @@ class _PermissionState extends State { void _listenForPermissionStatus() async { await _permissionHandler.checkPermissionStatus(widget._permission).then( - (status) => setState(() => _permissionStatus = status), - onError: (error, st) => debugPrint('$error')); + (status) => setState(() => _permissionStatus = status), + onError: (error, st) => debugPrint('$error'), + ); } Color getPermissionColor() { @@ -114,14 +117,14 @@ class _PermissionState extends State { ), trailing: (widget._permission is PermissionWithService) ? IconButton( - icon: const Icon( - Icons.info, - color: Colors.white, - ), + icon: const Icon(Icons.info, color: Colors.white), onPressed: () { checkServiceStatus( - context, widget._permission as PermissionWithService); - }) + context, + widget._permission as PermissionWithService, + ); + }, + ) : null, onTap: () { requestPermission(widget._permission); @@ -130,18 +133,24 @@ class _PermissionState extends State { } void checkServiceStatus( - BuildContext context, PermissionWithService permission) async { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - (await _permissionHandler.checkServiceStatus(permission)).toString()), - )); + BuildContext context, + PermissionWithService permission, + ) async { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + (await _permissionHandler.checkServiceStatus(permission)).toString(), + ), + ), + ); } Future requestPermission(Permission permission) async { await _permissionHandler.requestPermissions([permission]).then( - (status) => setState(() { - _permissionStatus = status[permission] ?? PermissionStatus.denied; - }), - onError: (error, st) => debugPrint('$error')); + (status) => setState(() { + _permissionStatus = status[permission] ?? PermissionStatus.denied; + }), + onError: (error, st) => debugPrint('$error'), + ); } } diff --git a/permission_handler_html/example/pubspec.yaml b/permission_handler_html/example/pubspec.yaml index f380c161a..122092b5b 100644 --- a/permission_handler_html/example/pubspec.yaml +++ b/permission_handler_html/example/pubspec.yaml @@ -2,8 +2,10 @@ name: permission_handler_html_example description: Demonstrates how to use the permission_handler_html plugin. environment: - sdk: ">=3.0.5 <4.0.0" + sdk: ^3.6.0 + flutter: ">=3.24.0" +resolution: workspace dependencies: permission_handler_platform_interface: ^4.2.0 baseflow_plugin_template: ^2.1.2 @@ -15,6 +17,7 @@ dev_dependencies: sdk: flutter flutter_test: sdk: flutter + flutter_lints: ^5.0.0 permission_handler_html: # When depending on this package from a real application you should use: diff --git a/permission_handler_html/lib/permission_handler_html.dart b/permission_handler_html/lib/permission_handler_html.dart index 8be2fa7c4..05b8afa0a 100644 --- a/permission_handler_html/lib/permission_handler_html.dart +++ b/permission_handler_html/lib/permission_handler_html.dart @@ -33,22 +33,18 @@ class WebPermissionHandler extends PermissionHandlerPlatform { /// Registers the web plugin implementation. static void registerWith(Registrar registrar) { PermissionHandlerPlatform.instance = WebPermissionHandler( - webDelegate: WebDelegate( - _devices, - _geolocation, - _htmlPermissions, - ), + webDelegate: WebDelegate(_devices, _geolocation, _htmlPermissions), ); } /// Constructs a WebPermissionHandler. - WebPermissionHandler({ - required WebDelegate webDelegate, - }) : _webDelegate = webDelegate; + WebPermissionHandler({required WebDelegate webDelegate}) + : _webDelegate = webDelegate; @override Future> requestPermissions( - List permissions) async { + List permissions, + ) async { return _webDelegate.requestPermissions(permissions); } @@ -64,7 +60,8 @@ class WebPermissionHandler extends PermissionHandlerPlatform { @override Future shouldShowRequestPermissionRationale( - Permission permission) async { + Permission permission, + ) async { return SynchronousFuture(false); } diff --git a/permission_handler_html/lib/web_delegate.dart b/permission_handler_html/lib/web_delegate.dart index 1db991418..32652c1f2 100644 --- a/permission_handler_html/lib/web_delegate.dart +++ b/permission_handler_html/lib/web_delegate.dart @@ -61,7 +61,9 @@ class WebDelegate { } Future _permissionStatusState( - String webPermissionName, web.Permissions? permissions) async { + String webPermissionName, + web.Permissions? permissions, + ) async { final webPermissionStatus = await permissions ?.query(_PermissionDescriptor(name: webPermissionName)) .toDart; @@ -91,8 +93,10 @@ class WebDelegate { audioTracks[0].stop(); } } - } on web.DOMException { - return false; + } catch (e) { + if (e is web.DOMException) { + return false; + } } return true; @@ -121,17 +125,19 @@ class WebDelegate { videoTracks[0].stop(); } } - } on web.DOMException { - return false; + } catch (e) { + if (e is web.DOMException) { + return false; + } } return true; } Future _requestNotificationPermission() async { - return web.Notification.requestPermission() - .toDart - .then((permission) => (permission == "granted".toJS)); + return web.Notification.requestPermission().toDart.then( + (permission) => (permission == "granted".toJS), + ); } Future _requestLocationPermission() async { @@ -152,14 +158,16 @@ class WebDelegate { } Future _requestSingularPermission( - Permission permission) async { + Permission permission, + ) async { bool permissionGranted = switch (permission) { Permission.microphone => await _requestMicrophonePermission(), Permission.camera => await _requestCameraPermission(), Permission.notification => await _requestNotificationPermission(), Permission.location => await _requestLocationPermission(), _ => throw UnsupportedError( - 'The ${permission.toString()} permission is currently not supported on web.') + 'The ${permission.toString()} permission is currently not supported on web.', + ), }; if (!permissionGranted) { @@ -173,13 +181,15 @@ class WebDelegate { /// /// Returns a [Map] containing the status per requested [Permission]. Future> requestPermissions( - List permissions) async { + List permissions, + ) async { final Map permissionStatusMap = {}; for (final permission in permissions) { try { - permissionStatusMap[permission] = - await _requestSingularPermission(permission); + permissionStatusMap[permission] = await _requestSingularPermission( + permission, + ); } on UnimplementedError { rethrow; } diff --git a/permission_handler_html/pubspec.yaml b/permission_handler_html/pubspec.yaml index 2b3a022e5..ccb28ca7a 100644 --- a/permission_handler_html/pubspec.yaml +++ b/permission_handler_html/pubspec.yaml @@ -1,12 +1,13 @@ name: permission_handler_html description: Permission plugin for Flutter. This plugin provides the web API to request and check permissions. -version: 0.1.3+5 +version: 0.1.4+1 homepage: https://github.com/baseflow/flutter-permission-handler environment: - sdk: ">=3.3.0 <4.0.0" - flutter: ">=3.16.0" + sdk: ^3.5.0 + flutter: ">=3.24.0" +resolution: workspace dependencies: flutter: @@ -19,7 +20,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^2.0.0 + flutter_lints: ^5.0.0 mockito: ^5.4.2 build_runner: ^2.1.2 test: ^1.24.4 diff --git a/permission_handler_platform_interface/CHANGELOG.md b/permission_handler_platform_interface/CHANGELOG.md index 825a49ee8..2d1d1a0b3 100644 --- a/permission_handler_platform_interface/CHANGELOG.md +++ b/permission_handler_platform_interface/CHANGELOG.md @@ -1,3 +1,11 @@ +## 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` + ## 4.3.0 - Updates project dependencies. diff --git a/permission_handler_platform_interface/lib/src/method_channel/method_channel_permission_handler.dart b/permission_handler_platform_interface/lib/src/method_channel/method_channel_permission_handler.dart index 5f03e3e7a..ca11e55d0 100644 --- a/permission_handler_platform_interface/lib/src/method_channel/method_channel_permission_handler.dart +++ b/permission_handler_platform_interface/lib/src/method_channel/method_channel_permission_handler.dart @@ -6,8 +6,9 @@ import 'package:flutter/services.dart'; import '../../permission_handler_platform_interface.dart'; import 'utils/codec.dart'; -const MethodChannel _methodChannel = - MethodChannel('flutter.baseflow.com/permissions/methods'); +const MethodChannel _methodChannel = MethodChannel( + 'flutter.baseflow.com/permissions/methods', +); /// An implementation of [PermissionHandlerPlatform] that uses [MethodChannel]s. class MethodChannelPermissionHandler extends PermissionHandlerPlatform { @@ -15,7 +16,9 @@ class MethodChannelPermissionHandler extends PermissionHandlerPlatform { @override Future checkPermissionStatus(Permission permission) async { final status = await _methodChannel.invokeMethod( - 'checkPermissionStatus', permission.value); + 'checkPermissionStatus', + permission.value, + ); return decodePermissionStatus(status); } @@ -52,7 +55,9 @@ class MethodChannelPermissionHandler extends PermissionHandlerPlatform { @override Future checkServiceStatus(Permission permission) async { final status = await _methodChannel.invokeMethod( - 'checkServiceStatus', permission.value); + 'checkServiceStatus', + permission.value, + ); return decodeServiceStatus(status); } @@ -74,10 +79,13 @@ class MethodChannelPermissionHandler extends PermissionHandlerPlatform { /// Returns a [Map] containing the status per requested [Permission]. @override Future> requestPermissions( - List permissions) async { + List permissions, + ) async { final data = encodePermissions(permissions); - final status = - await _methodChannel.invokeMethod('requestPermissions', data); + final status = await _methodChannel.invokeMethod( + 'requestPermissions', + data, + ); return decodePermissionRequestResult(Map.from(status)); } @@ -88,13 +96,16 @@ class MethodChannelPermissionHandler extends PermissionHandlerPlatform { /// returns [false]. @override Future shouldShowRequestPermissionRationale( - Permission permission) async { + Permission permission, + ) async { if (defaultTargetPlatform != TargetPlatform.android) { return false; } final shouldShowRationale = await _methodChannel.invokeMethod( - 'shouldShowRequestPermissionRationale', permission.value); + 'shouldShowRequestPermissionRationale', + permission.value, + ); return shouldShowRationale ?? false; } diff --git a/permission_handler_platform_interface/lib/src/method_channel/utils/codec.dart b/permission_handler_platform_interface/lib/src/method_channel/utils/codec.dart index dc5db075c..99bd0768a 100644 --- a/permission_handler_platform_interface/lib/src/method_channel/utils/codec.dart +++ b/permission_handler_platform_interface/lib/src/method_channel/utils/codec.dart @@ -13,9 +13,14 @@ ServiceStatus decodeServiceStatus(int value) { /// Converts the given [Map] of [int]s into a [Map] with [Permission]s as /// keys and their respective [PermissionStatus] as value. Map decodePermissionRequestResult( - Map value) { - return value.map((key, value) => MapEntry( - Permission.byValue(key), PermissionStatusValue.statusByValue(value))); + Map value, +) { + return value.map( + (key, value) => MapEntry( + Permission.byValue(key), + PermissionStatusValue.statusByValue(value), + ), + ); } /// Converts the given [List] of [Permission]s into a [List] of [int]s which diff --git a/permission_handler_platform_interface/lib/src/permission_handler_platform_interface.dart b/permission_handler_platform_interface/lib/src/permission_handler_platform_interface.dart index 32cb2b722..24685265e 100644 --- a/permission_handler_platform_interface/lib/src/permission_handler_platform_interface.dart +++ b/permission_handler_platform_interface/lib/src/permission_handler_platform_interface.dart @@ -32,7 +32,8 @@ abstract class PermissionHandlerPlatform extends PlatformInterface { /// Checks the current status of the given [Permission]. Future checkPermissionStatus(Permission permission) { throw UnimplementedError( - 'checkPermissionStatus() has not been implemented.'); + 'checkPermissionStatus() has not been implemented.', + ); } /// Checks the current status of the service associated with the given @@ -73,7 +74,8 @@ abstract class PermissionHandlerPlatform extends PlatformInterface { /// /// Returns a [Map] containing the status per requested [Permission]. Future> requestPermissions( - List permissions) { + List permissions, + ) { throw UnimplementedError('requestPermissions() has not been implemented.'); } @@ -83,6 +85,7 @@ abstract class PermissionHandlerPlatform extends PlatformInterface { /// returns [false]. Future shouldShowRequestPermissionRationale(Permission permission) { throw UnimplementedError( - 'shouldShowRequestPermissionRationale() has not been implemented.'); + 'shouldShowRequestPermissionRationale() has not been implemented.', + ); } } 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/lib/src/permissions.dart b/permission_handler_platform_interface/lib/src/permissions.dart index b81a99f28..8eb1f743f 100644 --- a/permission_handler_platform_interface/lib/src/permissions.dart +++ b/permission_handler_platform_interface/lib/src/permissions.dart @@ -326,6 +326,11 @@ class Permission { /// Permission for reading the current background refresh status. (iOS only) static const backgroundRefresh = Permission._(39); + /// Permission for using local network protocols (broad access) + /// + /// Android 17+ (API 37+) + static const accessLocalNetwork = Permission._(40); + /// Returns a list of all possible [PermissionGroup] values. static const List values = [ // ignore: deprecated_member_use_from_same_package @@ -369,6 +374,7 @@ class Permission { calendarFullAccess, assistant, backgroundRefresh, + accessLocalNetwork, ]; static const List _names = [ @@ -412,6 +418,7 @@ class Permission { 'calendarFullAccess', 'assistant', 'backgroundRefresh', + 'accessLocalNetwork', ]; @override diff --git a/permission_handler_platform_interface/pubspec.yaml b/permission_handler_platform_interface/pubspec.yaml index 586671633..88a95a917 100644 --- a/permission_handler_platform_interface/pubspec.yaml +++ b/permission_handler_platform_interface/pubspec.yaml @@ -3,11 +3,12 @@ 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.3.0 +version: 4.4.1 environment: - sdk: ^3.5.0 + sdk: ^3.6.0 flutter: ">=3.24.0" +resolution: workspace dependencies: flutter: diff --git a/permission_handler_platform_interface/test/src/method_channel/method_channel_mock.dart b/permission_handler_platform_interface/test/src/method_channel/method_channel_mock.dart index 2b5da964a..77e4671f4 100644 --- a/permission_handler_platform_interface/test/src/method_channel/method_channel_mock.dart +++ b/permission_handler_platform_interface/test/src/method_channel/method_channel_mock.dart @@ -19,8 +19,10 @@ class MethodChannelMock { Future _handler(MethodCall methodCall) async { if (methodCall.method != method) { - throw MissingPluginException('No implementation found for method ' - '$method on channel ${methodChannel.name}'); + throw MissingPluginException( + 'No implementation found for method ' + '$method on channel ${methodChannel.name}', + ); } return Future.delayed(delay, () { diff --git a/permission_handler_platform_interface/test/src/method_channel/method_channel_permission_handler_test.dart b/permission_handler_platform_interface/test/src/method_channel/method_channel_permission_handler_test.dart index c03c279a1..b39410a65 100644 --- a/permission_handler_platform_interface/test/src/method_channel/method_channel_permission_handler_test.dart +++ b/permission_handler_platform_interface/test/src/method_channel/method_channel_permission_handler_test.dart @@ -15,129 +15,140 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('checkPermissionStatus: When checking for permission', () { - test('Should receive granted if user wants access to the requested feature', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkPermissionStatus', - result: PermissionStatus.denied.value, - ); - - final permissionStatus = await MethodChannelPermissionHandler() - .checkPermissionStatus(Permission.contacts); - - expect(permissionStatus, PermissionStatus.denied); - }); - - test('Should receive denied if user denied access to the requested feature', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkPermissionStatus', - result: PermissionStatus.denied.value, - ); - - final permissionStatus = await MethodChannelPermissionHandler() - .checkPermissionStatus(Permission.contacts); - - expect(permissionStatus, PermissionStatus.denied); - }); - test( - // ignore: lines_longer_than_80_chars - 'Should receive restricted if OS denied rights for to the requested feature', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkPermissionStatus', - result: PermissionStatus.restricted.value, - ); + 'Should receive granted if user wants access to the requested feature', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkPermissionStatus', + result: PermissionStatus.denied.value, + ); - final permissionStatus = await MethodChannelPermissionHandler() - .checkPermissionStatus(Permission.contacts); + final permissionStatus = await MethodChannelPermissionHandler() + .checkPermissionStatus(Permission.contacts); - expect(permissionStatus, PermissionStatus.restricted); - }); + expect(permissionStatus, PermissionStatus.denied); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'Should receive limited if user has authorized this application for limited access', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkPermissionStatus', - result: PermissionStatus.limited.value, - ); + 'Should receive denied if user denied access to the requested feature', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkPermissionStatus', + result: PermissionStatus.denied.value, + ); - final permissionStatus = await MethodChannelPermissionHandler() - .checkPermissionStatus(Permission.contacts); + final permissionStatus = await MethodChannelPermissionHandler() + .checkPermissionStatus(Permission.contacts); - expect(permissionStatus, PermissionStatus.limited); - }); + expect(permissionStatus, PermissionStatus.denied); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'Should receive permanentlyDenied if user denied access and selected to never show a request for this permission again', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkPermissionStatus', - result: PermissionStatus.permanentlyDenied.value, - ); + // ignore: lines_longer_than_80_chars + 'Should receive restricted if OS denied rights for to the requested feature', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkPermissionStatus', + result: PermissionStatus.restricted.value, + ); + + final permissionStatus = await MethodChannelPermissionHandler() + .checkPermissionStatus(Permission.contacts); + + expect(permissionStatus, PermissionStatus.restricted); + }, + ); - final permissionStatus = await MethodChannelPermissionHandler() - .checkPermissionStatus(Permission.contacts); + test( + // ignore: lines_longer_than_80_chars + 'Should receive limited if user has authorized this application for limited access', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkPermissionStatus', + result: PermissionStatus.limited.value, + ); + + final permissionStatus = await MethodChannelPermissionHandler() + .checkPermissionStatus(Permission.contacts); + + expect(permissionStatus, PermissionStatus.limited); + }, + ); - expect(permissionStatus, PermissionStatus.permanentlyDenied); - }); + test( + // ignore: lines_longer_than_80_chars + 'Should receive permanentlyDenied if user denied access and selected to never show a request for this permission again', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkPermissionStatus', + result: PermissionStatus.permanentlyDenied.value, + ); + + final permissionStatus = await MethodChannelPermissionHandler() + .checkPermissionStatus(Permission.contacts); + + expect(permissionStatus, PermissionStatus.permanentlyDenied); + }, + ); }); group('checkServiceStatus: When checking for service', () { // ignore: lines_longer_than_80_chars test( - 'Should receive disabled if the service for the permission is disabled', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkServiceStatus', - result: ServiceStatus.disabled.value, - ); + 'Should receive disabled if the service for the permission is disabled', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkServiceStatus', + result: ServiceStatus.disabled.value, + ); - final serviceStatus = await MethodChannelPermissionHandler() - .checkServiceStatus(Permission.contacts); + final serviceStatus = await MethodChannelPermissionHandler() + .checkServiceStatus(Permission.contacts); - expect(serviceStatus, ServiceStatus.disabled); - }); + expect(serviceStatus, ServiceStatus.disabled); + }, + ); - test('Should receive enabled if the service for the permission is enabled', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkServiceStatus', - result: ServiceStatus.enabled.value, - ); + test( + 'Should receive enabled if the service for the permission is enabled', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkServiceStatus', + result: ServiceStatus.enabled.value, + ); - final serviceStatus = await MethodChannelPermissionHandler() - .checkServiceStatus(Permission.contacts); + final serviceStatus = await MethodChannelPermissionHandler() + .checkServiceStatus(Permission.contacts); - expect(serviceStatus, ServiceStatus.enabled); - }); + expect(serviceStatus, ServiceStatus.enabled); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'Should receive notApplicable if the permission does not have an associated service on the current platform', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'checkServiceStatus', - result: ServiceStatus.notApplicable.value, - ); - - final serviceStatus = await MethodChannelPermissionHandler() - .checkServiceStatus(Permission.contacts); - - expect(serviceStatus, ServiceStatus.notApplicable); - }); + // ignore: lines_longer_than_80_chars + 'Should receive notApplicable if the permission does not have an associated service on the current platform', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'checkServiceStatus', + result: ServiceStatus.notApplicable.value, + ); + + final serviceStatus = await MethodChannelPermissionHandler() + .checkServiceStatus(Permission.contacts); + + expect(serviceStatus, ServiceStatus.notApplicable); + }, + ); }); group('openAppSettings: When opening the App settings', () { @@ -170,36 +181,39 @@ void main() { group('requestPermissions: When requesting for permission', () { // ignore: lines_longer_than_80_chars - test('returns a Map with all the PermissionStatus of the given permissions', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'requestPermissions', - result: mockPermissionMap, - ); - - final result = await MethodChannelPermissionHandler() - .requestPermissions(mockPermissions); - - expect(result, isA>()); - }); + test( + 'returns a Map with all the PermissionStatus of the given permissions', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'requestPermissions', + result: mockPermissionMap, + ); + + final result = await MethodChannelPermissionHandler() + .requestPermissions(mockPermissions); + + expect(result, isA>()); + }, + ); }); group('shouldShowRequestPermissionRationale:', () { test( - // ignore: lines_longer_than_80_chars - 'should return true when you should show a rationale for requesting permission.', - () async { - MethodChannelMock( - channelName: 'flutter.baseflow.com/permissions/methods', - method: 'shouldShowRequestPermissionRationale', - result: true, - ); - - final shouldShowRationale = await MethodChannelPermissionHandler() - .shouldShowRequestPermissionRationale(mockPermissions.first); - - expect(shouldShowRationale, true); - }); + // ignore: lines_longer_than_80_chars + 'should return true when you should show a rationale for requesting permission.', + () async { + MethodChannelMock( + channelName: 'flutter.baseflow.com/permissions/methods', + method: 'shouldShowRequestPermissionRationale', + result: true, + ); + + final shouldShowRationale = await MethodChannelPermissionHandler() + .shouldShowRequestPermissionRationale(mockPermissions.first); + + expect(shouldShowRationale, true); + }, + ); }); } diff --git a/permission_handler_platform_interface/test/src/method_channel/utils/coded_test.dart b/permission_handler_platform_interface/test/src/method_channel/utils/coded_test.dart index 1742b9fc6..2932a6406 100644 --- a/permission_handler_platform_interface/test/src/method_channel/utils/coded_test.dart +++ b/permission_handler_platform_interface/test/src/method_channel/utils/coded_test.dart @@ -15,9 +15,7 @@ void main() { test( 'decodePermissionRequestResult should convert a map' 'to map', () { - var value = { - 1: 1, - }; + var value = {1: 1}; var permissionMap = decodePermissionRequestResult(value); diff --git a/permission_handler_platform_interface/test/src/permission_handler_platform_interface_test.dart b/permission_handler_platform_interface/test/src/permission_handler_platform_interface_test.dart index c965e72ed..157602eef 100644 --- a/permission_handler_platform_interface/test/src/permission_handler_platform_interface_test.dart +++ b/permission_handler_platform_interface/test/src/permission_handler_platform_interface_test.dart @@ -9,8 +9,10 @@ void main() { group('$PermissionHandlerPlatform', () { test('$MethodChannelPermissionHandler is the default instance', () { - expect(PermissionHandlerPlatform.instance, - isA()); + expect( + PermissionHandlerPlatform.instance, + isA(), + ); }); test('Cannot be implemented with `implements`', () { @@ -30,61 +32,71 @@ void main() { }); test( - // ignore: lines_longer_than_80_chars - 'Default implementation of checkPermissionStatus should throw unimplemented error', - () { - final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); - - expect(() { - permissionHandlerPlatform - .checkPermissionStatus(Permission.accessMediaLocation); - }, throwsUnimplementedError); - }); + // ignore: lines_longer_than_80_chars + 'Default implementation of checkPermissionStatus should throw unimplemented error', + () { + final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); + + expect(() { + permissionHandlerPlatform.checkPermissionStatus( + Permission.accessMediaLocation, + ); + }, throwsUnimplementedError); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'Default implementation of checkServiceStatus should throw unimplemented error', - () { - final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); - - expect(() { - permissionHandlerPlatform - .checkServiceStatus(Permission.accessMediaLocation); - }, throwsUnimplementedError); - }); + // ignore: lines_longer_than_80_chars + 'Default implementation of checkServiceStatus should throw unimplemented error', + () { + final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); + + expect(() { + permissionHandlerPlatform.checkServiceStatus( + Permission.accessMediaLocation, + ); + }, throwsUnimplementedError); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'Default implementation of openAppSettings should throw unimplemented error', - () { - final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); - - expect( - permissionHandlerPlatform.openAppSettings, throwsUnimplementedError); - }); + // ignore: lines_longer_than_80_chars + 'Default implementation of openAppSettings should throw unimplemented error', + () { + final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); + + expect( + permissionHandlerPlatform.openAppSettings, + throwsUnimplementedError, + ); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'Default implementation of requestPermissions should throw unimplemented error', - () { - final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); - var permission = [Permission.accessMediaLocation]; - - expect(() { - permissionHandlerPlatform.requestPermissions(permission); - }, throwsUnimplementedError); - }); + // ignore: lines_longer_than_80_chars + 'Default implementation of requestPermissions should throw unimplemented error', + () { + final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); + var permission = [Permission.accessMediaLocation]; + + expect(() { + permissionHandlerPlatform.requestPermissions(permission); + }, throwsUnimplementedError); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'Default implementation of shouldShowRequestPermissionRationale should throw unimplemented error', - () { - final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); - expect(() { - permissionHandlerPlatform.shouldShowRequestPermissionRationale( - Permission.accessMediaLocation); - }, throwsUnimplementedError); - }); + // ignore: lines_longer_than_80_chars + 'Default implementation of shouldShowRequestPermissionRationale should throw unimplemented error', + () { + final permissionHandlerPlatform = ExtendsPermissionHandlerPlatform(); + expect(() { + permissionHandlerPlatform.shouldShowRequestPermissionRationale( + Permission.accessMediaLocation, + ); + }, throwsUnimplementedError); + }, + ); }); } diff --git a/permission_handler_platform_interface/test/src/permission_status_test.dart b/permission_handler_platform_interface/test/src/permission_status_test.dart index d26ee6b32..3e127754d 100644 --- a/permission_handler_platform_interface/test/src/permission_status_test.dart +++ b/permission_handler_platform_interface/test/src/permission_status_test.dart @@ -32,19 +32,32 @@ void main() { }); test( - // ignore: lines_longer_than_80_chars - 'statusByValue should return right index int that corresponds with the right PermissionStatus', - () { - expect(PermissionStatusValue.statusByValue(0), PermissionStatus.denied); - expect(PermissionStatusValue.statusByValue(1), PermissionStatus.granted); - expect( - PermissionStatusValue.statusByValue(2), PermissionStatus.restricted); - expect(PermissionStatusValue.statusByValue(3), PermissionStatus.limited); - expect(PermissionStatusValue.statusByValue(4), - PermissionStatus.permanentlyDenied); - expect( - PermissionStatusValue.statusByValue(5), PermissionStatus.provisional); - }); + // ignore: lines_longer_than_80_chars + 'statusByValue should return right index int that corresponds with the right PermissionStatus', + () { + expect(PermissionStatusValue.statusByValue(0), PermissionStatus.denied); + expect( + PermissionStatusValue.statusByValue(1), + PermissionStatus.granted, + ); + expect( + PermissionStatusValue.statusByValue(2), + PermissionStatus.restricted, + ); + expect( + PermissionStatusValue.statusByValue(3), + PermissionStatus.limited, + ); + expect( + PermissionStatusValue.statusByValue(4), + PermissionStatus.permanentlyDenied, + ); + expect( + PermissionStatusValue.statusByValue(5), + PermissionStatus.provisional, + ); + }, + ); }); group('PermissionStatusGetters', () { @@ -76,11 +89,15 @@ void main() { expect(await mockFuture(PermissionStatus.restricted).isRestricted, true); expect(await mockFuture(PermissionStatus.limited).isLimited, true); expect( - await mockFuture(PermissionStatus.permanentlyDenied) - .isPermanentlyDenied, - true); + await mockFuture( + PermissionStatus.permanentlyDenied, + ).isPermanentlyDenied, + true, + ); expect( - await mockFuture(PermissionStatus.provisional).isProvisional, true); + await mockFuture(PermissionStatus.provisional).isProvisional, + true, + ); }); test('Getters should return false if statement is not met', () async { @@ -89,7 +106,9 @@ void main() { expect(await mockFuture(PermissionStatus.restricted).isDenied, false); expect(await mockFuture(PermissionStatus.limited).isDenied, false); expect( - await mockFuture(PermissionStatus.permanentlyDenied).isDenied, false); + await mockFuture(PermissionStatus.permanentlyDenied).isDenied, + false, + ); expect(await mockFuture(PermissionStatus.provisional).isDenied, false); }); }); diff --git a/permission_handler_platform_interface/test/src/permissions_test.dart b/permission_handler_platform_interface/test/src/permissions_test.dart index 0f8beb470..78160ddae 100644 --- a/permission_handler_platform_interface/test/src/permissions_test.dart +++ b/permission_handler_platform_interface/test/src/permissions_test.dart @@ -5,7 +5,7 @@ void main() { test('Permission has the right amount of possible Permission values', () { const values = Permission.values; - expect(values.length, 40); + expect(values.length, 41); }); test('check if byValue returns corresponding Permission value', () { @@ -32,47 +32,42 @@ void main() { }); test( - // ignore: lines_longer_than_80_chars - 'equality operator should return true for two instances with the same values', - () { - // Arrange - final firstPermission = Permission.byValue(1); - final secondPermission = Permission.byValue(1); + // ignore: lines_longer_than_80_chars + 'equality operator should return true for two instances with the same values', + () { + // Arrange + final firstPermission = Permission.byValue(1); + final secondPermission = Permission.byValue(1); - // Act & Assert - expect( - firstPermission == secondPermission, - true, - ); - }); + // Act & Assert + expect(firstPermission == secondPermission, true); + }, + ); test( - // ignore: lines_longer_than_80_chars - 'equality operator should return false for two instances with different values', - () { - // Arrange - final firstPermission = Permission.byValue(1); - final secondPermission = Permission.byValue(2); + // ignore: lines_longer_than_80_chars + 'equality operator should return false for two instances with different values', + () { + // Arrange + final firstPermission = Permission.byValue(1); + final secondPermission = Permission.byValue(2); - // Act & Assert - expect( - firstPermission == secondPermission, - false, - ); - }); + // Act & Assert + expect(firstPermission == secondPermission, false); + }, + ); - test('hashCode should be the same for two instances with the same values', - () { - // Arrange - final firstPermission = Permission.byValue(1); - final secondPermission = Permission.byValue(1); + test( + 'hashCode should be the same for two instances with the same values', + () { + // Arrange + final firstPermission = Permission.byValue(1); + final secondPermission = Permission.byValue(1); - // Act & Assert - expect( - firstPermission.hashCode, - secondPermission.hashCode, - ); - }); + // Act & Assert + expect(firstPermission.hashCode, secondPermission.hashCode); + }, + ); test('hashCode should not match for two instances with different values', () { // Arrange @@ -80,9 +75,6 @@ void main() { final secondPermission = Permission.byValue(2); // Act & Assert - expect( - firstPermission.hashCode == secondPermission.hashCode, - false, - ); + expect(firstPermission.hashCode == secondPermission.hashCode, false); }); } diff --git a/permission_handler_platform_interface/test/src/service_status_test.dart b/permission_handler_platform_interface/test/src/service_status_test.dart index b5db02424..6dad5489c 100644 --- a/permission_handler_platform_interface/test/src/service_status_test.dart +++ b/permission_handler_platform_interface/test/src/service_status_test.dart @@ -26,13 +26,17 @@ void main() { }); test( - // ignore: lines_longer_than_80_chars - 'statusByValue should return right index int that corresponds with the right PermissionStatus', - () { - expect(ServiceStatusValue.statusByValue(0), ServiceStatus.disabled); - expect(ServiceStatusValue.statusByValue(1), ServiceStatus.enabled); - expect(ServiceStatusValue.statusByValue(2), ServiceStatus.notApplicable); - }); + // ignore: lines_longer_than_80_chars + 'statusByValue should return right index int that corresponds with the right PermissionStatus', + () { + expect(ServiceStatusValue.statusByValue(0), ServiceStatus.disabled); + expect(ServiceStatusValue.statusByValue(1), ServiceStatus.enabled); + expect( + ServiceStatusValue.statusByValue(2), + ServiceStatus.notApplicable, + ); + }, + ); }); group('ServiceStatusGetters', () { diff --git a/permission_handler_windows/CHANGELOG.md b/permission_handler_windows/CHANGELOG.md index 91c2f5049..f951b347d 100644 --- a/permission_handler_windows/CHANGELOG.md +++ b/permission_handler_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.2.2 + +* Added support for the new Android 17 permission `ACCESS_LOCAL_NETWORK` + ## 0.2.1 * Updates the dependency on `permission_handler_platform_interface` to version 4.1.0 (SiriKit support is only available for iOS and macOS). diff --git a/permission_handler_windows/example/lib/main.dart b/permission_handler_windows/example/lib/main.dart index 58a4dc053..4e5f30ca1 100644 --- a/permission_handler_windows/example/lib/main.dart +++ b/permission_handler_windows/example/lib/main.dart @@ -1,50 +1,62 @@ +// ignore_for_file: avoid_print + import 'package:baseflow_plugin_template/baseflow_plugin_template.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart'; void main() { - runApp(BaseflowPluginExample( + runApp( + BaseflowPluginExample( pluginName: 'Permission Handler', githubURL: 'https://github.com/Baseflow/flutter-permission-handler', pubDevURL: 'https://pub.dev/packages/permission_handler', - pages: [PermissionHandlerWidget.createPage()])); + pages: [PermissionHandlerWidget.createPage()], + ), + ); } ///Defines the main theme color final MaterialColor themeMaterialColor = BaseflowPluginExample.createMaterialColor( - const Color.fromRGBO(48, 49, 60, 1)); + const Color.fromRGBO(48, 49, 60, 1), +); /// A Flutter application demonstrating the functionality of this plugin class PermissionHandlerWidget extends StatefulWidget { + /// Creates a [PermissionHandlerWidget] that listens for permission status changes. + const PermissionHandlerWidget({super.key}); + /// Create a page containing the functionality of this plugin static ExamplePage createPage() { return ExamplePage( - Icons.location_on, (context) => PermissionHandlerWidget()); + Icons.location_on, + (context) => PermissionHandlerWidget(), + ); } @override - _PermissionHandlerWidgetState createState() => - _PermissionHandlerWidgetState(); + PermissionHandlerWidgetState createState() => PermissionHandlerWidgetState(); } -class _PermissionHandlerWidgetState extends State { +/// State for the [PermissionHandlerWidget] that listens for permission status changes. +class PermissionHandlerWidgetState extends State { @override Widget build(BuildContext context) { return Center( child: ListView( - children: Permission.values - .where((permission) { - return permission != Permission.unknown && - permission != Permission.mediaLibrary && - permission != Permission.photos && - permission != Permission.photosAddOnly && - permission != Permission.reminders && - permission != Permission.appTrackingTransparency && - permission != Permission.criticalAlerts; - }) - .map((permission) => PermissionWidget(permission)) - .toList()), + children: Permission.values + .where((permission) { + return permission != Permission.unknown && + permission != Permission.mediaLibrary && + permission != Permission.photos && + permission != Permission.photosAddOnly && + permission != Permission.reminders && + permission != Permission.appTrackingTransparency && + permission != Permission.criticalAlerts; + }) + .map((permission) => PermissionWidget(permission)) + .toList(), + ), ); } } @@ -52,18 +64,21 @@ class _PermissionHandlerWidgetState extends State { /// Permission widget containing information about the passed [Permission] class PermissionWidget extends StatefulWidget { /// Constructs a [PermissionWidget] for the supplied [Permission] - const PermissionWidget(this._permission); + const PermissionWidget(this._permission, {super.key}); final Permission _permission; + /// Returns the [Permission] associated with this widget. + Permission get permission => _permission; + @override - _PermissionState createState() => _PermissionState(_permission); + PermissionWidgetState createState() => PermissionWidgetState(); } -class _PermissionState extends State { - _PermissionState(this._permission); - - final Permission _permission; +/// State for the [PermissionWidget] that listens for permission status changes. +class PermissionWidgetState extends State { + /// Constructs a [PermissionWidgetState] for the supplied [PermissionWidget]. + PermissionWidgetState(); final PermissionHandlerPlatform _permissionHandler = PermissionHandlerPlatform.instance; PermissionStatus _permissionStatus = PermissionStatus.denied; @@ -76,10 +91,12 @@ class _PermissionState extends State { } void _listenForPermissionStatus() async { - final status = await _permissionHandler.checkPermissionStatus(_permission); + final status = + await _permissionHandler.checkPermissionStatus(widget.permission); setState(() => _permissionStatus = status); } + /// Returns the color to use for the permission status. Color getPermissionColor() { switch (_permissionStatus) { case PermissionStatus.denied: @@ -97,38 +114,45 @@ class _PermissionState extends State { Widget build(BuildContext context) { return ListTile( title: Text( - _permission.toString(), + widget.permission.toString(), style: Theme.of(context).textTheme.bodyLarge, ), subtitle: Text( _permissionStatus.toString(), style: TextStyle(color: getPermissionColor()), ), - trailing: (_permission is PermissionWithService) + trailing: (widget.permission is PermissionWithService) ? IconButton( - icon: const Icon( - Icons.info, - color: Colors.white, - ), + icon: const Icon(Icons.info, color: Colors.white), onPressed: () { checkServiceStatus( - context, _permission as PermissionWithService); - }) + context, + widget.permission as PermissionWithService, + ); + }, + ) : null, onTap: () { - requestPermission(_permission); + requestPermission(widget.permission); }, ); } + /// Requests permission for the given [Permission]. void checkServiceStatus( - BuildContext context, PermissionWithService permission) async { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text( - (await _permissionHandler.checkServiceStatus(permission)).toString()), - )); + BuildContext context, + PermissionWithService permission, + ) async { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + (await _permissionHandler.checkServiceStatus(permission)).toString(), + ), + ), + ); } + /// Requests permission for the given [Permission]. Future requestPermission(Permission permission) async { final status = await _permissionHandler.requestPermissions([permission]); diff --git a/permission_handler_windows/example/pubspec.yaml b/permission_handler_windows/example/pubspec.yaml index 2deb8e72c..0c9e1d4f5 100644 --- a/permission_handler_windows/example/pubspec.yaml +++ b/permission_handler_windows/example/pubspec.yaml @@ -1,33 +1,29 @@ -name: permission_handler_windows_example -description: Demonstrates how to use the permission_handler_windows plugin. - -environment: - sdk: ">=2.15.0 <3.0.0" - -dependencies: - baseflow_plugin_template: ^2.1.1 - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - - permission_handler_windows: - # When depending on this package from a real application you should use: - # permission_handler_windows: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. - path: ../ - - url_launcher: ^6.0.12 - -flutter: - uses-material-design: true - - assets: - - res/images/baseflow_logo_def_light-02.png - - res/images/poweredByBaseflowLogoLight@3x.png - - packages/baseflow_plugin_template/logo.png - - packages/baseflow_plugin_template/poweredByBaseflow.png +name: permission_handler_windows_example +description: Demonstrates how to use the permission_handler_windows plugin. + +environment: + sdk: ^3.6.0 + flutter: ">=3.24.0" +resolution: workspace + +dependencies: + baseflow_plugin_template: ^2.1.1 + flutter: + sdk: flutter + permission_handler_windows: ^0.2.2 + permission_handler_platform_interface: ^4.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + url_launcher: ^6.0.12 + +flutter: + uses-material-design: true + + assets: + - res/images/baseflow_logo_def_light-02.png + - res/images/poweredByBaseflowLogoLight@3x.png + - packages/baseflow_plugin_template/logo.png + - packages/baseflow_plugin_template/poweredByBaseflow.png diff --git a/permission_handler_windows/pubspec.yaml b/permission_handler_windows/pubspec.yaml index f5553e0d6..b01412999 100644 --- a/permission_handler_windows/pubspec.yaml +++ b/permission_handler_windows/pubspec.yaml @@ -1,6 +1,6 @@ name: permission_handler_windows description: Permission plugin for Flutter. This plugin provides the Windows API to request and check permissions. -version: 0.2.1 +version: 0.2.2 homepage: https://github.com/baseflow/flutter-permission-handler flutter: @@ -21,5 +21,6 @@ dev_dependencies: plugin_platform_interface: ^2.0.0 environment: - sdk: ">=2.12.0 <4.0.0" - flutter: ">=2.0.0" + sdk: ^3.6.0 + flutter: ">=3.24.0" +resolution: workspace diff --git a/permission_handler_windows/windows/permission_constants.h b/permission_handler_windows/windows/permission_constants.h index 1bc10737c..d0c580d68 100644 --- a/permission_handler_windows/windows/permission_constants.h +++ b/permission_handler_windows/windows/permission_constants.h @@ -50,7 +50,8 @@ class PermissionConstants { CALENDAR_WRITE_ONLY = 36, CALENDAR_FULL_ACCESS = 37, ASSISTANT = 38, - BACKGROUND_REFRESH = 39 + BACKGROUND_REFRESH = 39, + ACCESS_LOCAL_NETWORK = 40, }; //PERMISSION_STATUS diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 000000000..56e2c0439 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,21 @@ +name: permission_handler_monorepo +publish_to: none +environment: + sdk: ^3.6.0 + flutter: ">=3.24.0" + +workspace: + - permission_handler + - permission_handler/example + - permission_handler_android + - permission_handler_android/example + - permission_handler_apple + - permission_handler_apple/example + - permission_handler_html + - permission_handler_html/example + - permission_handler_platform_interface + - permission_handler_windows + - permission_handler_windows/example + +dev_dependencies: + flutter_lints: ^5.0.0