diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a654a9..4ed72c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,7 +195,11 @@ jobs: # compiled the way the CLI compiles it, with -fsyntax-only, for a watch # and for the Simulator. macos-26 runs the oldest Xcode 26 the CLI # supports and its default; xcode-27 runs Xcode 27. A symbol one SDK lacks - # (CLActivityTypeMaritime in #12) fails here, not in an app's build. + # (CLActivityTypeMaritime in #12) fails here, not in an app's build. A + # plugin that declares `defines` is compiled with no -D, with the marker + # and every macro, with the marker alone, and with the marker and each + # macro alone (#19), so each guard compiles with its macros all on, all + # off and one at a time. name: native sources (${{ matrix.label }}) runs-on: ${{ matrix.runner }} timeout-minutes: 20 @@ -214,6 +218,15 @@ jobs: steps: - uses: actions/checkout@v4 + # The check reads each plugin's `defines` with + # tool/bin/plugin_defines.dart, through package:yaml as the CLI reads + # them, so Dart and the tool's dependencies come before the compile. + - uses: dart-lang/setup-dart@v1 + + - name: Fetch the tool dependencies + working-directory: tool + run: dart pub get + - name: Compile with each Xcode env: DEVELOPER_DIRS: ${{ matrix.developer_dirs }} @@ -230,12 +243,10 @@ jobs: exit "$status" # The check must be able to fail. Its fixtures hold an undeclared - # symbol and a warning, which fail, and a clean source with a - # deprecation, which passes. - - uses: dart-lang/setup-dart@v1 - + # symbol, a warning, guards in C and Swift that are wrong only for an + # app without their key, and a nested guard wrong only for one macro + # alone, which fail, and a clean source with a deprecation and guards + # right in every pass, which pass. - name: Test the check working-directory: tool - run: | - dart pub get - dart test test/check_native_sources_test.dart + run: dart test test/check_native_sources_test.dart diff --git a/AUTHORING.md b/AUTHORING.md index 383dd24..d68b4d2 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -46,7 +46,9 @@ time). A working watchOS plugin therefore ships native code as through dead-stripping, and default visibility puts it in the binary's export table, where `DynamicLibrary.process()` finds it. The CLI also sets `STRIP_STYLE = non-global`, so the strip of an App Store build keeps the - exports. §3 shows how to check a build. + exports. §3 shows how to check a build. A plugin can also map macros to + app Info.plist keys under `defines`, to leave out the code for an API an + app does not use (§2e). 4. **Dart** — a class extending the upstream `*_platform_interface`, with a `static void registerWith()` that installs itself as the default instance, and methods that call the FFI bindings. Keep the bindings in @@ -259,6 +261,80 @@ Constraints: The four `firebase_*_watchos` packages (core, auth, storage, messaging) are worked examples of all of the above. +## 2e. Compiling out what an app does not use + +App Store Connect asks for the usage description of each privacy-sensitive +API a binary references (ITMS-90683), whether or not the app calls it. A +plugin that covers several such APIs can leave out the ones an app does not +use, as permission_handler's iOS implementation does with its `PERMISSION_*` +macros. Map each macro to the app Info.plist key that turns it on, under +`defines:` next to `ffiSymbols`: + +```yaml +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true + dartPluginClass: Watchos + defines: + _CONTACTS: NSContactsUsageDescription + _LOCATION: + - NSLocationWhenInUseUsageDescription + - NSLocationAlwaysAndWhenInUseUsageDescription +``` + +From the first flutter-watchos release after 0.1.1, the CLI compiles the +plugin's native sources with `-D=1` (in Swift, `-D`) for each +macro whose key the watch app's Info.plist sets. A plugin that declares +`defines` also gets the marker, `FLUTTER_WATCHOS_PLUGIN_DEFINES`, on every +source. A plugin without `defines`, or whose `defines` is misspelled or +misplaced, does not get it, and compiles as before. An older CLI passes +neither, so guard each group to keep it in apps built by one: + +```objc +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || _CONTACTS +// The contacts code. +#endif +``` + +- Give a group that is left out an answer: `permission_handler_watchos` + answers as the iOS implementation answers a permission it does not know. + Where upstream compiles a group in with any of several macros, keep its + answers for the parts whose own macro is off, as + `permission_handler_watchos` does for calendar and reminders. Leave such a + part out instead only when keeping it would put into the app something + the app must then declare, and say so in the README. The one case in + `permission_handler_watchos` is location: under + `PERMISSION_LOCATION_WHENINUSE` alone it answers `locationAlways` as a + permission it does not know, where upstream reads its status, so that an + app that only asks for "when in use" has no `NSUserDefaults` flag, a + required-reason API its privacy manifest would have to declare. +- Keep the macros and keys in the pubspec and in the sources paired: a + misspelled macro, or a key filed under the wrong one, puts the wrong code + into every app, and nothing fails to compile. File each key under one + macro. A key read in code that several macros compile in belongs to the + one no other key turns on: `NSLocationWhenInUseUsageDescription`, read + under `PERMISSION_LOCATION || PERMISSION_LOCATION_WHENINUSE`, belongs to + `PERMISSION_LOCATION_WHENINUSE`. `permission_handler_watchos` has tests + that check each key the native code reads against the guards around it + this way. +- `tool/check_native_sources.sh` compiles such a plugin with no `-D`, with + the marker and every macro, with the marker alone, and with the marker and + each macro alone (see `tool/README.md`). +- The CLI compiles plugin sources on every `flutter-watchos build` and + `run` (and `drive`, and `test` with `-d`); an Xcode build or archive does + not. `run` and `test` stage a debug build for one SDK, and `test` without + `-d` compiles no plugin source, so after an Info.plist edit an app runs + `flutter-watchos build watchos --release` again before it archives. +- If the CLI cannot read the app's Info.plist, it warns and compiles the + plugin without its macros and without the marker, as an older CLI does. + +The rules, which values turn a macro on, what `-v` prints and why a plugin +built through SwiftPM (§2d) is not covered, are in the CLI's +`doc/plugins.md`, from the first flutter-watchos release after 0.1.1. +`permission_handler_watchos` is the worked example. + ## 3. Verify ```sh diff --git a/packages/permission_handler_watchos/CHANGELOG.md b/packages/permission_handler_watchos/CHANGELOG.md index b9425b2..f1dbe30 100644 --- a/packages/permission_handler_watchos/CHANGELOG.md +++ b/packages/permission_handler_watchos/CHANGELOG.md @@ -10,13 +10,32 @@ physical watch. and Siri (`assistant`, for an app that sets `PermissionHandlerWatchosSiri` in its Info.plist), with the iOS implementation's statuses and error codes. `storage` is granted, as on iOS. +* Each group is compiled into the app only when the watch app's Info.plist + sets one of its keys, as the iOS implementation's `PERMISSION_*` macros + do: the pubspec maps the same macros to the keys under `defines`, which + the first flutter-watchos release after 0.1.1 applies. A group left out + answers as an unknown permission does, and none of its class names, + selectors or keys are in the binary. Calendar and reminders are one + group, as on iOS: with only some of their keys, each status is still + read, and a request whose prompt is left out answers its status. The + write-only prompt still needs its own key: with only the full-access key, + a `calendarWriteOnly` request while it is not determined fails with + `MISSING_USAGE_DESCRIPTION`, where iOS sends the write-only request. With + only the when-in-use key, `locationAlways` is left out too. Siri opts in + only when `PermissionHandlerWatchosSiri` is the Boolean YES or a string + YES, true or 1: any non-empty string compiles Siri's code in, but `NO` + leaves Siri off. +* A notifications-only app built this way references the same privacy API + as a native watch app that only asks for notifications. Under + flutter-watchos 0.1.1 or earlier, or when the CLI cannot read the + Info.plist, every group is compiled in, and its names stay in the binary + as strings, which App Store Connect's usage-description check may read + (see the README). * Each framework but UserNotifications is loaded at runtime instead of - linked. Every group's class names and selectors are still in the binary, - unlike the iOS implementation, whose build macros leave a group out; - whether App Store Connect's usage-description check reads them is not yet - known (see the README). -* The location strategy keeps one flag in `NSUserDefaults`, so apps declare - `NSPrivacyAccessedAPICategoryUserDefaults` (`CA92.1`) in their privacy + linked. +* The location strategy keeps one flag in `NSUserDefaults`, for "always", + so an app that compiles "always" in declares + `NSPrivacyAccessedAPICategoryUserDefaults` (`CA92.1`) in its privacy manifest. * A request whose usage description is missing fails with `MISSING_USAGE_DESCRIPTION` instead of the app being terminated. A request diff --git a/packages/permission_handler_watchos/PORTING_REPORT.md b/packages/permission_handler_watchos/PORTING_REPORT.md index 4b546fc..af184d7 100644 --- a/packages/permission_handler_watchos/PORTING_REPORT.md +++ b/packages/permission_handler_watchos/PORTING_REPORT.md @@ -6,12 +6,13 @@ |---|---| | Implementation | ✅ Working (FFI; one strategy per permission group, as in the iOS implementation) | | watchOS capability | ◐ The permissions watchOS has: location (when in use and always), notifications, critical alerts, sensors, microphone, contacts, calendar (full access and write only), reminders, Bluetooth, Siri; storage is granted as on iOS. The others answer as iOS answers for a permission it does not know | -| Host unit tests (`flutter test`) | ✅ 8 pass | +| Host unit tests (`flutter test`) | ✅ 15 pass; 7 of them check the pubspec's `defines` against the native guards: each macro lists a key, and each key is filed under one macro, the one the guards around the code that reads it leave it; and the write-only request is compiled in with either calendar macro, as on iOS | | Upstream integration test | ○ none upstream (Baseflow's example is a manual demo) | | watchOS integration test (`example/integration_test/permission_handler_test.dart`) | ✅ 5 pass on the watchOS 27.0 Simulator | | System prompts | ✅ asked for on the Simulator, each answered as on iOS: notification granted; location when in use granted; location always kept at "while using" → permanentlyDenied; sensors granted; microphone granted; contacts refused → permanentlyDenied; reminders granted; calendar write only granted. Bluetooth reads granted on the Simulator, which has no Bluetooth. After the review fixes, location when in use again: the prompt closed with the crown → answered, and a second request prompted again; allowed → granted; "always" asked for right after → its prompt stayed up until answered, kept at "while using" → permanentlyDenied. In upstream's example: calendar (full access) allowed → granted | | Critical alerts and Siri prompts | not yet asked for: each needs an entitlement the example does not have | -| App Store Connect upload | not yet done: whether ITMS-90683 reads the class names of groups an app does not use is unchecked (see "Design") | +| Compile-time groups | ✅ `tool/check_native_sources.sh` compiles the sources with no `-D` (flutter-watchos 0.1.1 or earlier), with the marker and every macro, with the marker alone, and with the marker and each macro alone, for a watch and the Simulator; each pair of the events macros was compiled too (Xcode 27.1, no warning). Objects compiled with the marker alone hold none of the groups' class names, selectors or keys (`strings`), and none of their frameworks' link entries; with `PERMISSION_LOCATION_WHENINUSE` alone, no "always" request and no `NSUserDefaults` reference (`nm -u`). The events strategy, built for macOS with EventKit's status and requests stubbed, answered every combination of its three macros as the iOS implementation does when the Info.plist has every key: each status read, the service not applicable, a request whose prompt is left out answering its status without a prompt. When the Info.plist has only the keys of the macros that are on, it differs in one case: with the full-access key and no write-only key, a `calendarWriteOnly` request while it is not determined fails with `MISSING_USAGE_DESCRIPTION`, where iOS sends the write-only request (see Design) | +| Privacy footprint against a native app (in place of an App Store Connect upload) | ✅ 2026-10-30, with `nm -u`, `otool` and `strings -a`. A native SwiftUI watch app that only calls `UNUserNotificationCenter.requestAuthorization` references one privacy API, `UNUserNotificationCenter`. A Flutter watch app that only requests `Permission.notification` through this package, with no usage keys, built as a debug Simulator app by a flutter-watchos that applies `defines` (`-v`: none of the 10 on), references the same and nothing else: no other group's class names, request selectors or usage keys. Both link the Swift CoreLocation overlay that SwiftUI pulls in, and reference no location class. So no upload is needed. Built by a flutter-watchos without `defines`, as 0.1.1, the same app links no other framework and references no other framework's class (`NSUserDefaults` is referenced, for the location flag), but every group's class names, request selectors and usage keys are in it as strings | | Example deviations | `example/pubspec.yaml` adds `url_launcher_watchos`, for the links of `baseflow_plugin_template`, which go through `url_launcher`. `example/lib/main.dart` is otherwise upstream's, formatted as this repo's CI checks | | Physical watch | not yet checked | @@ -26,24 +27,60 @@ The iOS implementation compiles each permission group in or out with a preprocessor macro (`PERMISSION_CONTACTS=1` and so on, all off by default), so that an app does not reference an API it never asks for: App Store Connect warns about a missing usage description (ITMS-90683) for the privacy APIs a -binary references. A watch app has no Podfile to set those macros, and the -flutter-watchos CLI compiles plugin sources with fixed flags, so every group -is compiled into every app. Each framework is loaded with `dlopen` and its -classes looked up by name the first time a permission of its group is asked -about, and only messaged; of the frameworks, only UserNotifications, which -needs no usage description, is linked. `nm -u` on the compiled objects shows -no class symbol of CoreLocation, CoreMotion, AVFAudio, Contacts, EventKit, -CoreBluetooth or Intents. That is not the iOS result: the class names, -selectors and delegate protocol metadata are still in the binary, and Apple -does not document whether its check reads those. Until an App Store Connect -upload of an app that uses only notifications shows no ITMS-90683, the README -tells apps to expect to need every group's usage description. Leaving groups -out at compile time needs a CLI feature. +binary references. A watch app has no Podfile to set those macros. Instead, +the pubspec maps the same macros to the Info.plist keys each group's prompts +check, under `defines`, and from the first flutter-watchos release after +0.1.1 the CLI reads the app's Info.plist on every build and compiles the +plugin with the macro of each group whose key is set, after +`FLUTTER_WATCHOS_PLUGIN_DEFINES`. Each group's file guards its code with +`#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_X` and gives the +dispatch table, through one function, either its strategy or +`PHWUnknownStrategy`, which answers as the iOS implementation answers a group +whose macros are all off. A group left out takes its framework's class names, +selectors, usage-description keys and import out of the binary. + +The calendar and reminders group follows the iOS implementation's macros: +any of `PERMISSION_EVENTS_FULL_ACCESS`, `PERMISSION_EVENTS` and +`PERMISSION_REMINDERS` compiles its strategy in, which then reads the real +EventKit status of all four permissions and answers the service as not +applicable. Each macro adds the request of its own key's prompt, and +`PERMISSION_EVENTS_FULL_ACCESS` adds the write-only request too, as on iOS. +A request whose prompt is left out answers its status without a prompt. One +difference remains: before a prompt this package checks that prompt's own +key (see below), so the write-only prompt still needs +`NSCalendarsWriteOnlyAccessUsageDescription`. With only the full-access key, +`calendarWriteOnly` is granted once full access is, and a request while it +is not determined fails with `MISSING_USAGE_DESCRIPTION`, where iOS sends +the write-only request. + +Location goes further than the iOS implementation. There, +`PERMISSION_LOCATION_WHENINUSE` alone still compiles the whole location +strategy, `locationAlways` included, and only the "always" request is left +out. Here `PERMISSION_LOCATION_WHENINUSE` alone compiles in "when in use", +and `locationAlways` answers as an unknown permission, without its request +and its `NSUserDefaults` flag; `PERMISSION_LOCATION` compiles in "always" +too. + +A flutter-watchos that predates `defines` passes neither the macros nor the +marker, and nor does a later one that cannot read the app's Info.plist, so +every group is compiled into every app, as before. Each framework +is loaded with `dlopen` and its classes looked up by name the first time a +permission of its group is asked about, and only messaged; of the +frameworks, only UserNotifications, which needs no usage description, is +linked. `nm -u` on the compiled objects shows no class symbol of +CoreLocation, CoreMotion, AVFAudio, Contacts, EventKit, CoreBluetooth or +Intents. That is not the iOS result: the class names, selectors and delegate +protocol metadata are still in the binary, and Apple does not document +whether its check reads those, so the README tells apps built with such a +flutter-watchos to expect to need every group's usage description. Built +with `defines`, a notifications-only app has the privacy footprint of a +native one (see "Verification status"). The location strategy keeps one flag in `NSUserDefaults` (whether "always" was asked for), a required-reason API; the iOS implementation ships a -privacy manifest for the same reason. The README shows the entry an app's -manifest needs. +privacy manifest for the same reason. The flag is compiled in with +`PERMISSION_LOCATION` only, or with no `defines` applied. The README shows +the entry an app's manifest needs. watchOS terminates an app that asks for a permission without its usage description. Before each prompt this package checks the Info.plist key the @@ -82,10 +119,10 @@ watchOS would not show again. | `sensors` | ✅ CMMotionActivityManager, asked for with an activity query as on iOS; service status is `isActivityAvailable` | | `microphone` | ✅ AVAudioApplication's record permission (watchOS 10 and later) | | `contacts` | ✅ CNContactStore; the watchOS SDK has no limited access | -| `calendarFullAccess`, `calendarWriteOnly`, `calendar`, `reminders` | ✅ EKEventStore, with the full-access and write-only requests | +| `calendarFullAccess`, `calendarWriteOnly`, `calendar`, `reminders` | ✅ EKEventStore, with the full-access and write-only requests. With only some of the group's keys, each status is still read, and a request whose prompt is left out answers its status without a prompt, as on iOS; the write-only prompt needs its own key even with the full-access key, where iOS does not check (see Design) | | `bluetooth` | ✅ CBCentralManager: creating it asks, its first state update answers; service status is whether it is powered on | | `criticalAlerts` | ✅ UNUserNotificationCenter's critical alert setting and request; needs the Critical Alerts entitlement | -| `assistant` | ◐ INPreferences (watchOS 6+), Siri, for an app that opts in; needs the Siri capability. INPreferences throws in an app without it (seen on the Simulator), so the Info.plist key `PermissionHandlerWatchosSiri` (`YES`) is the opt-in, in place of the iOS implementation's `PERMISSION_ASSISTANT` macro; `NSSiriUsageDescription`, which apps add with every other group's, does not switch it on. Without the opt-in the status is `denied` and so is a request; with it and no `NSSiriUsageDescription`, a request fails with `MISSING_USAGE_DESCRIPTION`. A per-app compile-time opt-in is #19 | +| `assistant` | ◐ INPreferences (watchOS 6+), Siri, for an app that opts in; needs the Siri capability. INPreferences throws in an app without it (seen on the Simulator), so the Info.plist key `PermissionHandlerWatchosSiri` (`YES`) is the opt-in, and from the first flutter-watchos release after 0.1.1 the same key turns the iOS implementation's `PERMISSION_ASSISTANT` macro on. Only the Boolean `YES`, or a string `YES`, `true` or `1` in any case, opts in. `NSSiriUsageDescription`, which apps on an older flutter-watchos add with every other group's, does not switch it on. Without the opt-in Siri is never asked, so an app without the capability keeps running, and its status is `denied`. If Siri's code is left out, which the CLI does when the key does not count (a non-empty string or `` counts), Siri answers as an unknown permission: a request `permanentlyDenied`, the service `disabled`. If the code is in, as with any other non-empty string (`NO`, say), under 0.1.1 or earlier, which compiles every group in, or when the CLI cannot read the Info.plist, a request answers `denied` and the service is not applicable. With the opt-in and no `NSSiriUsageDescription`, a request fails with `MISSING_USAGE_DESCRIPTION` | | `storage` | ✅ granted, service not applicable, as on iOS | | `camera`, `photos`, `photosAddOnly`, `mediaLibrary`, `speech`, `appTrackingTransparency`, `backgroundRefresh`, `phone` | ✗ not on watchOS: `denied`, a request `permanentlyDenied`, service `disabled`, as iOS answers for an unknown permission | | The Android-only permissions | answered as an unknown permission, as on iOS | diff --git a/packages/permission_handler_watchos/README.md b/packages/permission_handler_watchos/README.md index e6edce5..c1982e2 100644 --- a/packages/permission_handler_watchos/README.md +++ b/packages/permission_handler_watchos/README.md @@ -33,31 +33,101 @@ Add the usage description of each permission you ask for to the watch app's `NSCalendarsFullAccessUsageDescription`, `NSCalendarsWriteOnlyAccessUsageDescription`, `NSRemindersFullAccessUsageDescription`, `NSBluetoothAlwaysUsageDescription`, -`NSSiriUsageDescription`. A request whose key is missing fails with a -`PlatformException` whose code is `MISSING_USAGE_DESCRIPTION`, where watchOS -would terminate the app. Siri also needs its own opt-in, only with the Siri +`NSSiriUsageDescription`. Siri also needs its own opt-in, only with the Siri capability (see `assistant` below), because asking about Siri without the -capability ends the app. +capability ends the app. When its group is compiled in (see below), a +request whose prompt needs a key the Info.plist lacks fails with a +`PlatformException` whose code is `MISSING_USAGE_DESCRIPTION`, where watchOS +would terminate the app. + +### Compile-time groups + +As in the iOS implementation, a permission group's code is compiled into the +app only when the app uses the group, which it says by setting one of the +group's keys in the watch app's Info.plist. This takes the first +flutter-watchos release after 0.1.1: on every build, it reads +`watchos/Runner/Info.plist` and defines the macro of each key the app +sets, as the package's pubspec maps them under `defines`. + +| Group | Permissions | Macro | Info.plist keys, any one | +|---|---|---|---| +| Location, when in use | `location`, `locationWhenInUse` | `PERMISSION_LOCATION_WHENINUSE` | `NSLocationWhenInUseUsageDescription` | +| Location, always too | `location`, `locationWhenInUse`, `locationAlways` | `PERMISSION_LOCATION` | `NSLocationAlwaysAndWhenInUseUsageDescription`, `NSLocationAlwaysUsageDescription` | +| Sensors | `sensors` | `PERMISSION_SENSORS` | `NSMotionUsageDescription` | +| Microphone | `microphone` | `PERMISSION_MICROPHONE` | `NSMicrophoneUsageDescription` | +| Contacts | `contacts` | `PERMISSION_CONTACTS` | `NSContactsUsageDescription` | +| Calendar and reminders | `calendar`, `calendarFullAccess`, `calendarWriteOnly`, `reminders` | `PERMISSION_EVENTS_FULL_ACCESS`, `PERMISSION_EVENTS`, `PERMISSION_REMINDERS`, one for each key in turn | `NSCalendarsFullAccessUsageDescription`, `NSCalendarsWriteOnlyAccessUsageDescription`, `NSRemindersFullAccessUsageDescription` | +| Bluetooth | `bluetooth` | `PERMISSION_BLUETOOTH` | `NSBluetoothAlwaysUsageDescription` | +| Siri | `assistant` | `PERMISSION_ASSISTANT` | `PermissionHandlerWatchosSiri`, a Boolean set to `YES` | + +- A key counts when it holds a non-empty string, `NO` included, or the + Boolean `YES`. Set `PermissionHandlerWatchosSiri` to the Boolean `YES`. A + string compiles Siri's code in too, but only `YES`, `true` or `1`, in any + case, opts in when the app runs: `NO` compiles the code in + and leaves Siri off. +- `notification`, `criticalAlerts` and `storage` need no key, and are always + compiled in. +- When none of a permission's keys counts, it answers as a permission + watchOS does not have, as the iOS implementation answers a group whose + macros are all off: its status is `denied`, a request answers + `permanentlyDenied` without a prompt, and its service is `disabled`. +- Calendar and reminders are one group, as on iOS: any one of its three + keys compiles it in, and all four of its permissions then read their real + status and answer the service as not applicable. Each key adds its own + prompt: the full-access key the one of `calendar` and `calendarFullAccess`, + the write-only key the one of `calendarWriteOnly`, and the reminders key + the one of `reminders`. A request whose prompt is left out answers its + status without a prompt, as on iOS. +- The full-access key compiles the write-only request in too, as on iOS, + but the write-only prompt still needs its own key, + `NSCalendarsWriteOnlyAccessUsageDescription`. With only the full-access + key, `calendarWriteOnly` is granted once full access is, and a request + while it is not determined fails with `MISSING_USAGE_DESCRIPTION`, where + iOS sends the write-only request. +- With `NSLocationWhenInUseUsageDescription` alone, `locationAlways` is left + out and answers as a permission watchOS does not have, with no "always" + request and no `NSUserDefaults` flag in the app. The iOS implementation + keeps its location code for `locationAlways` there, and reads its status. +- If flutter-watchos cannot read `watchos/Runner/Info.plist`, it warns and + compiles every group in, as an older flutter-watchos does. +- After an Info.plist edit, run `flutter-watchos build watchos --release` + again before you archive. An Xcode build or archive does not compile the + groups again, `run` and `test` stage a debug build for one SDK, and `test` + without `-d` compiles no plugin source. + +With flutter-watchos 0.1.1 or earlier, every group is compiled into every +app, whatever its Info.plist says. Each permission you ask for still needs +its key, as above, and expect the app to need the usage description of +every group it never asks for too (see App Review). ### App Review -Every permission group's code is in every app that uses this package. The -iOS implementation leaves a group out at compile time unless its build macro -is set, so App Store Connect sees no privacy API an app does not use. A watch -app has no Podfile to set such macros, and the flutter-watchos CLI compiles -plugin sources with fixed flags. Here the frameworks other than -UserNotifications are not linked and are loaded only when a permission of -their group is asked about, but each group's class names, selectors and -delegate metadata are still in the binary. Whether App Store Connect's check -for missing usage descriptions (ITMS-90683) reads those is not documented, -and no upload has checked it yet: until one has, expect an app to need the -usage description of every group, the ones it never asks for included. +App Store Connect asks for the usage description of each privacy-sensitive +API a binary references (ITMS-90683). With the first flutter-watchos release +after 0.1.1, a group the app's Info.plist leaves out is not in the binary: +none of its framework's class names, selectors or usage-description keys +are. A watch app that only asks for notifications then references the same +privacy API as a native watch app that does only that, +`UNUserNotificationCenter`, and nothing else. So an app needs only the keys +of the groups it uses, as on iOS. + +With flutter-watchos 0.1.1 or earlier, every group's code is in every app +that uses this package. The frameworks other than UserNotifications are not +linked and are loaded only when a permission of their group is asked about, +but each group's class names, request selectors and usage-description keys +stay in the binary as strings. Whether App Store Connect's check reads those +is not documented, so expect such an app to need the usage description of +every group, the ones it never asks for included. The location strategy keeps one flag in `NSUserDefaults`, whether "always" was asked for, since watchOS shows that prompt once. `NSUserDefaults` is a -required-reason API and this code is in every app that uses the package, so -the app's privacy manifest (`PrivacyInfo.xcprivacy`) declares -`NSPrivacyAccessedAPICategoryUserDefaults` with reason `CA92.1`: +required-reason API, so an app with this code declares +`NSPrivacyAccessedAPICategoryUserDefaults` with reason `CA92.1` in its +privacy manifest (`PrivacyInfo.xcprivacy`). With the first flutter-watchos +release after 0.1.1, that is an app whose Info.plist sets +`NSLocationAlwaysAndWhenInUseUsageDescription` or +`NSLocationAlwaysUsageDescription`, or whose Info.plist the CLI cannot read; +with an older one, every app that uses the package: ```xml NSPrivacyAccessedAPITypes @@ -85,7 +155,7 @@ the app's privacy manifest (`PrivacyInfo.xcprivacy`) declares | `calendarFullAccess`, `calendarWriteOnly`, `reminders` | supported, with the full-access and write-only prompts | | `bluetooth` | supported, with the service status | | `criticalAlerts` | supported; needs the Critical Alerts entitlement (`com.apple.developer.usernotifications.critical-alerts`), which Apple grants on request | -| `assistant` | Siri, supported for an app that opts in: watchOS ends an app without the Siri capability (`com.apple.developer.siri`) that asks about Siri, so the iOS implementation's build macro is an Info.plist key here, `PermissionHandlerWatchosSiri` set to `YES`, which only an app with the capability sets, with `NSSiriUsageDescription`. Without it the status is `denied`, a request too, and Siri is never asked | +| `assistant` | Siri, supported for an app that opts in: watchOS ends an app without the Siri capability (`com.apple.developer.siri`) that asks about Siri, so the iOS implementation's build macro is an Info.plist key here, `PermissionHandlerWatchosSiri` set to `YES`, which only an app with the capability sets, with `NSSiriUsageDescription`. Only the Boolean `YES`, or a string `YES`, `true` or `1` in any case, opts in. Without the opt-in Siri is never asked and its status is `denied`. If Siri's code is left out, which a flutter-watchos after 0.1.1 does when the key does not count (see Compile-time groups), a request answers `permanentlyDenied` and the service is `disabled`. If the code is in, as with any other non-empty string (`NO`, say), under flutter-watchos 0.1.1 or earlier, or when flutter-watchos cannot read the Info.plist, a request answers `denied` and the service is not applicable | | `storage` | granted, as on iOS: the app's own container needs no permission | `shouldShowRequestPermissionRationale` is false, as on iOS. The statuses and diff --git a/packages/permission_handler_watchos/pubspec.yaml b/packages/permission_handler_watchos/pubspec.yaml index 215b0ae..0f87a30 100644 --- a/packages/permission_handler_watchos/pubspec.yaml +++ b/packages/permission_handler_watchos/pubspec.yaml @@ -27,6 +27,9 @@ dev_dependencies: flutter_lints: ^4.0.0 flutter_test: sdk: flutter + # test/compile_time_groups_test.dart reads the pubspec's `defines` with + # the parser the CLI uses. + yaml: ^3.1.3 flutter: plugin: @@ -50,3 +53,26 @@ flutter: - permission_handler_watchos_request - permission_handler_watchos_take_result - permission_handler_watchos_release + # Each permission group's native code is compiled in only when the + # app's Info.plist sets one of its keys: from the first + # flutter-watchos release after 0.1.1, the CLI then defines the + # group's macro, and an older one compiles every group in. The names + # are the iOS implementation's PERMISSION_* macros, each keyed to the + # usage descriptions its prompts check. Notifications, critical + # alerts and storage need no key and are always compiled in. + defines: + PERMISSION_LOCATION_WHENINUSE: NSLocationWhenInUseUsageDescription + # "Always" too, with the NSUserDefaults flag it keeps. + PERMISSION_LOCATION: + - NSLocationAlwaysAndWhenInUseUsageDescription + - NSLocationAlwaysUsageDescription + PERMISSION_SENSORS: NSMotionUsageDescription + PERMISSION_MICROPHONE: NSMicrophoneUsageDescription + PERMISSION_CONTACTS: NSContactsUsageDescription + PERMISSION_EVENTS_FULL_ACCESS: NSCalendarsFullAccessUsageDescription + PERMISSION_EVENTS: NSCalendarsWriteOnlyAccessUsageDescription + PERMISSION_REMINDERS: NSRemindersFullAccessUsageDescription + PERMISSION_BLUETOOTH: NSBluetoothAlwaysUsageDescription + # Siri's opt-in: a Boolean, set to YES only with the Siri + # capability, since asking about Siri without it ends the app. + PERMISSION_ASSISTANT: PermissionHandlerWatchosSiri diff --git a/packages/permission_handler_watchos/test/compile_time_groups_test.dart b/packages/permission_handler_watchos/test/compile_time_groups_test.dart new file mode 100644 index 0000000..6a9fe18 --- /dev/null +++ b/packages/permission_handler_watchos/test/compile_time_groups_test.dart @@ -0,0 +1,281 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by the MIT license that can be found +// in the LICENSE file. +// +// The compile-time groups: the pubspec's `defines` and the native sources' +// guards pair the same macros with the same keys. Each Info.plist key the +// native code reads must be declared under a macro that the guard around +// that code tests, and each declared key must be read in code its macro +// compiles in. A macro or a key spelled differently, or a key filed under +// the wrong macro, would put the wrong groups into every app that a +// flutter-watchos with `defines` builds, and no compile would show it. +// +// The pubspec files each key under one macro, and the guards decide which. +// A key read in code that several macros compile in, such as the +// when-in-use key under PERMISSION_LOCATION || PERMISSION_LOCATION_WHENINUSE, +// belongs to the one that no other key turns on. Filed under the other one +// too, it would turn that one on as well, and put its code into apps that +// do not use it. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +/// A guard of the marker form: `#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES)` +/// followed by `|| MACRO` for each macro it tests. +final RegExp _markerGuard = RegExp( + r'^#if !defined\(FLUTTER_WATCHOS_PLUGIN_DEFINES\)((?:\s*\|\|\s*\w+)+)\s*$', +); + +/// An Info.plist key, as an Objective-C string literal: a usage description +/// or this package's own Siri opt-in. +final RegExp _keyLiteral = RegExp( + r'@"(NS\w+UsageDescription|PermissionHandlerWatchos\w*)"', +); + +/// Siri's usage description is read in Siri's code, but it does not turn +/// that code on: its opt-in does, as the usage description alone does not +/// say that the app has the Siri capability. +const String _siriUsageDescription = 'NSSiriUsageDescription'; + +/// One Info.plist key literal in the native sources, with the macros of the +/// innermost marker guard around it: empty outside any marker guard, and in +/// the `#else` side of one. +class _KeyRead { + _KeyRead(this.key, this.macros, this.where); + + final String key; + final Set macros; + final String where; +} + +/// The native sources, sorted by path. +List _sources() { + return Directory('watchos') + .listSync(recursive: true) + .whereType() + .where((File f) => f.path.endsWith('.m') || f.path.endsWith('.h')) + .toList() + ..sort((File a, File b) => a.path.compareTo(b.path)); +} + +/// The lines of [file], with each continued line joined to the next. +List _lines(File file) => + file.readAsStringSync().replaceAll('\\\n', ' ').split('\n'); + +/// The macros that the marker guards in the native sources test. +Set _guardedMacros() { + return { + for (final File file in _sources()) + for (final String line in _lines(file)) + if (_markerGuard.firstMatch(line.trim()) case final RegExpMatch guard) + for (final RegExpMatch macro in RegExp(r'\w+').allMatches(guard[1]!)) + macro[0]!, + }; +} + +/// Every Info.plist key literal in the native sources, with its guard. +List<_KeyRead> _keyReads() { + final List<_KeyRead> reads = <_KeyRead>[]; + for (final File file in _sources()) { + // Each open #if: the macros of a marker guard on its `#if` side, an empty + // set on its `#else` side, and null for any other #if. + final List?> open = ?>[]; + final List lines = _lines(file); + for (int i = 0; i < lines.length; i++) { + final String line = lines[i].trim(); + if (line.startsWith('#if')) { + final RegExpMatch? guard = _markerGuard.firstMatch(line); + open.add(guard == null + ? null + : RegExp(r'\w+') + .allMatches(guard[1]!) + .map((RegExpMatch m) => m[0]!) + .toSet()); + } else if (line.startsWith('#el')) { + if (open.last != null) { + open.last = {}; + } + } else if (line.startsWith('#endif')) { + open.removeLast(); + } else { + final Set macros = open.lastWhere( + (Set? guard) => guard != null, + orElse: () => {}, + ) ?? + {}; + for (final RegExpMatch key in _keyLiteral.allMatches(line)) { + reads.add(_KeyRead(key[1]!, macros, '${file.path}:${i + 1}')); + } + } + } + expect(open, isEmpty, reason: 'unbalanced #if in ${file.path}'); + } + return reads; +} + +/// The pubspec's `defines`: each macro with its Info.plist keys, none for an +/// entry that lists none. +Map> _declared() { + Object? node = loadYaml(File('pubspec.yaml').readAsStringSync()); + for (final String key in [ + 'flutter', + 'plugin', + 'platforms', + 'watchos', + 'defines' + ]) { + node = (node as YamlMap)[key]; + } + return >{ + for (final MapEntry entry in (node as YamlMap).entries) + entry.key as String: switch (entry.value) { + final String key => [key], + final YamlList keys => keys.cast().toList(), + _ => [], + }, + }; +} + +/// The macros the pubspec files each declared key under. +Map> _macrosOfKey() { + final Map> macros = >{}; + for (final MapEntry> define in _declared().entries) { + for (final String key in define.value) { + (macros[key] ??= {}).add(define.key); + } + } + return macros; +} + +/// The macros the guards leave each key the native code reads, from the +/// sources alone: those that compile in every read of the key. Where that +/// is several, a macro that no other key is left takes the key, since each +/// macro needs a key and a key turns one macro on. A key still left several +/// is one the guards do not decide. +Map> _macrosTheGuardsLeave() { + final Map> left = >{}; + for (final _KeyRead read in _keyReads()) { + if (read.key != _siriUsageDescription) { + left[read.key] = + left[read.key]?.intersection(read.macros) ?? read.macros.toSet(); + } + } + final Set macros = _guardedMacros(); + bool narrowed = true; + while (narrowed) { + narrowed = false; + for (final String macro in macros) { + final List keys = [ + for (final MapEntry> entry in left.entries) + if (entry.value.contains(macro)) entry.key, + ]; + if (keys.length == 1 && left[keys.single]!.length > 1) { + left[keys.single] = {macro}; + narrowed = true; + } + } + } + return left; +} + +/// [macrosOfKey] as sorted `key: MACRO` lines, for a readable failure. +List _pairs(Map> macrosOfKey) { + return [ + for (final MapEntry> entry in macrosOfKey.entries) + '${entry.key}: ${(entry.value.toList()..sort()).join(', ')}', + ]..sort(); +} + +void main() { + test('the guards test exactly the macros the pubspec declares', () { + expect(_guardedMacros(), _declared().keys.toSet()); + }); + + test('each macro lists a key, and each key is listed once', () { + final Map> declared = _declared(); + expect( + [ + for (final MapEntry> define in declared.entries) + if (define.value.isEmpty) define.key, + ], + isEmpty, + reason: 'macros that list no key', + ); + final List keys = [ + for (final List listed in declared.values) ...listed, + ]; + expect( + { + for (final String key in keys) + if (keys.where((String other) => other == key).length > 1) key, + }, + isEmpty, + reason: 'keys listed more than once', + ); + }); + + test('each key the native code reads is filed under a macro its guard tests', + () { + final Map> macrosOfKey = _macrosOfKey(); + for (final _KeyRead read in _keyReads()) { + if (read.key == _siriUsageDescription) { + expect(macrosOfKey[read.key], isNull, reason: read.where); + expect(read.macros, contains('PERMISSION_ASSISTANT'), + reason: read.where); + continue; + } + expect(macrosOfKey[read.key], isNotNull, + reason: '${read.key} at ${read.where} is filed under no macro'); + expect(read.macros, containsAll(macrosOfKey[read.key]!), + reason: '${read.key} at ${read.where}'); + } + }); + + test('each declared key is read in code that its macro compiles in', () { + final List<_KeyRead> reads = _keyReads(); + for (final MapEntry> define in _declared().entries) { + for (final String key in define.value) { + expect( + reads.where((_KeyRead read) => + read.key == key && read.macros.contains(define.key)), + isNotEmpty, + reason: '${define.key}: $key', + ); + } + } + }); + + test('each key is filed under exactly the macro the guards leave it', () { + expect(_pairs(_macrosOfKey()), _pairs(_macrosTheGuardsLeave())); + }); + + // The iOS implementation compiles the write-only request in with + // PERMISSION_EVENTS or PERMISSION_EVENTS_FULL_ACCESS, and so does this + // package. A guard that tests PERMISSION_EVENTS alone leaves the key to the + // same macro, so the test above cannot tell the two apart. + test('the write-only request is compiled in with either calendar macro', () { + expect( + >[ + for (final _KeyRead read in _keyReads()) + if (read.key == 'NSCalendarsWriteOnlyAccessUsageDescription') + read.macros.toList()..sort(), + ], + >[ + ['PERMISSION_EVENTS', 'PERMISSION_EVENTS_FULL_ACCESS'], + ], + ); + }); + + test('the EventKit keys are among the keys read', () { + expect( + {for (final _KeyRead read in _keyReads()) read.key}, + containsAll([ + 'NSCalendarsFullAccessUsageDescription', + 'NSCalendarsWriteOnlyAccessUsageDescription', + 'NSRemindersFullAccessUsageDescription', + ]), + ); + }); +} diff --git a/packages/permission_handler_watchos/watchos/Classes/permission_handler_watchos_ffi.m b/packages/permission_handler_watchos/watchos/Classes/permission_handler_watchos_ffi.m index 2078c9a..5eabc9f 100644 --- a/packages/permission_handler_watchos/watchos/Classes/permission_handler_watchos_ffi.m +++ b/packages/permission_handler_watchos/watchos/Classes/permission_handler_watchos_ffi.m @@ -39,6 +39,20 @@ BOOL phw_has_key(NSString* key) { return [[NSBundle mainBundle] objectForInfoDictionaryKey:key] != nil; } +BOOL phw_says_yes(id value) { + if ([value isKindOfClass:[NSString class]]) { + for (NSString* yes in @[ @"YES", @"true", @"1" ]) { + if ([value caseInsensitiveCompare:yes] == NSOrderedSame) { + return YES; + } + } + return NO; + } + // A plist Boolean is a CFBoolean; an integer is a CFNumber, even 1. + return value != nil && CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID() && + CFBooleanGetValue((__bridge CFBooleanRef)value); +} + void phw_missing_key(NSString* key, PHWErrorHandler failed) { failed(@"MISSING_USAGE_DESCRIPTION", [NSString stringWithFormat:@"The app's Info.plist has no %@, which the system " @@ -107,7 +121,8 @@ static PHWErrorHandler _failer(int64_t request) { // ---- Strategies ------------------------------------------------------------- -// Main queue only. +// Main queue only. A group the build left out answers through +// PHWUnknownStrategy: its file's function says which (phw_internal.h). static id _strategy(int32_t permission) { static NSMutableDictionary>* strategies; if (strategies == nil) { @@ -121,25 +136,25 @@ static PHWErrorHandler _failer(int64_t request) { case PHWPermissionLocation: case PHWPermissionLocationAlways: case PHWPermissionLocationWhenInUse: - kind = [PHWLocationStrategy class]; + kind = phw_location_strategy((PHWPermission)permission); break; case PHWPermissionSensors: - kind = [PHWMotionStrategy class]; + kind = phw_motion_strategy(); break; case PHWPermissionMicrophone: - kind = [PHWMicrophoneStrategy class]; + kind = phw_microphone_strategy(); break; case PHWPermissionContacts: - kind = [PHWContactsStrategy class]; + kind = phw_contacts_strategy(); break; case PHWPermissionCalendar: case PHWPermissionCalendarWriteOnly: case PHWPermissionCalendarFullAccess: case PHWPermissionReminders: - kind = [PHWEventsStrategy class]; + kind = phw_events_strategy(); break; case PHWPermissionBluetooth: - kind = [PHWBluetoothStrategy class]; + kind = phw_bluetooth_strategy(); break; case PHWPermissionStorage: kind = [PHWStorageStrategy class]; @@ -148,7 +163,7 @@ static PHWErrorHandler _failer(int64_t request) { kind = [PHWCriticalAlertsStrategy class]; break; case PHWPermissionAssistant: - kind = [PHWAssistantStrategy class]; + kind = phw_assistant_strategy(); break; default: kind = [PHWUnknownStrategy class]; diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_assistant.m b/packages/permission_handler_watchos/watchos/Classes/phw_assistant.m index 65abf46..b0ea278 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_assistant.m +++ b/packages/permission_handler_watchos/watchos/Classes/phw_assistant.m @@ -10,12 +10,23 @@ // cannot be caught and asked again. The iOS implementation only compiles this // in when the app sets PERMISSION_ASSISTANT. Here the opt-in is the // Info.plist key below, set to YES, which an app adds only with the Siri -// capability; a usage description alone does not switch it on, since apps -// are told to add every group's. Without the opt-in, Siri is never asked -// about: the status is denied, and so is a request. +// capability. A usage description alone does not switch it on: an app built +// by a flutter-watchos that predates `defines` adds every group's. +// +// The same key turns PERMISSION_ASSISTANT on under a flutter-watchos that +// applies `defines`, so without it none of this is in the app and Siri +// answers as PHWUnknownStrategy. An older flutter-watchos compiles this in +// for every app, so the key is still read here, and only a yes opts in: the +// Boolean YES, or a string YES, true or 1 (phw_says_yes). The CLI compiles +// this in for any non-empty string, so a string NO compiles it in but leaves +// Siri off, and an app without the Siri capability keeps running. Without +// the opt-in, Siri is never asked about, the status is denied, and so is a +// request. #import "phw_internal.h" +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_ASSISTANT + #import #import @@ -23,8 +34,7 @@ static NSString* const kSiriOptIn = @"PermissionHandlerWatchosSiri"; static BOOL _optedIn(void) { - id value = [[NSBundle mainBundle] objectForInfoDictionaryKey:kSiriOptIn]; - return [value isKindOfClass:[NSNumber class]] && [value boolValue]; + return phw_says_yes([[NSBundle mainBundle] objectForInfoDictionaryKey:kSiriOptIn]); } static PHWStatus _assistantStatus(NSInteger status) { @@ -41,6 +51,9 @@ static PHWStatus _assistantStatus(NSInteger status) { return PHWStatusDenied; } +@interface PHWAssistantStrategy : NSObject +@end + @implementation PHWAssistantStrategy - (Class)preferencesClass { @@ -89,3 +102,15 @@ - (void)requestPermission:(PHWPermission)permission } @end + +Class phw_assistant_strategy(void) { + return [PHWAssistantStrategy class]; +} + +#else + +Class phw_assistant_strategy(void) { + return [PHWUnknownStrategy class]; +} + +#endif diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_bluetooth.m b/packages/permission_handler_watchos/watchos/Classes/phw_bluetooth.m index 8977cb4..080a0b7 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_bluetooth.m +++ b/packages/permission_handler_watchos/watchos/Classes/phw_bluetooth.m @@ -5,9 +5,16 @@ // Bluetooth, through a CBCentralManager the strategy keeps: creating it is // what asks the wearer, and its first state update is the answer, as in the // iOS implementation. CoreBluetooth is loaded at runtime and only messaged. +// +// Compiled in only when the app's Info.plist sets +// NSBluetoothAlwaysUsageDescription (PERMISSION_BLUETOOTH), or by a +// flutter-watchos that predates `defines`. Otherwise Bluetooth answers as +// PHWUnknownStrategy, and none of this is in the app. #import "phw_internal.h" +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_BLUETOOTH + #import static PHWStatus _bluetoothStatus(NSInteger authorization) { @@ -24,7 +31,7 @@ static PHWStatus _bluetoothStatus(NSInteger authorization) { return PHWStatusDenied; } -@interface PHWBluetoothStrategy () +@interface PHWBluetoothStrategy : NSObject @end @implementation PHWBluetoothStrategy { @@ -122,3 +129,15 @@ - (void)centralManagerDidUpdateState:(CBCentralManager*)central { } @end + +Class phw_bluetooth_strategy(void) { + return [PHWBluetoothStrategy class]; +} + +#else + +Class phw_bluetooth_strategy(void) { + return [PHWUnknownStrategy class]; +} + +#endif diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_contacts.m b/packages/permission_handler_watchos/watchos/Classes/phw_contacts.m index 71ebd6f..a34afbe 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_contacts.m +++ b/packages/permission_handler_watchos/watchos/Classes/phw_contacts.m @@ -4,9 +4,16 @@ // // Contacts, through CNContactStore. Contacts is loaded at runtime and only // messaged. +// +// Compiled in only when the app's Info.plist sets NSContactsUsageDescription +// (PERMISSION_CONTACTS), or by a flutter-watchos that predates `defines`. +// Otherwise contacts answers as PHWUnknownStrategy, and none of this is in +// the app. #import "phw_internal.h" +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_CONTACTS + #import static PHWStatus _contactsStatus(NSInteger status) { @@ -26,6 +33,9 @@ static PHWStatus _contactsStatus(NSInteger status) { } } +@interface PHWContactsStrategy : NSObject +@end + @implementation PHWContactsStrategy { // Kept until its request answers. CNContactStore* _store; @@ -79,3 +89,15 @@ - (void)requestPermission:(PHWPermission)permission } @end + +Class phw_contacts_strategy(void) { + return [PHWContactsStrategy class]; +} + +#else + +Class phw_contacts_strategy(void) { + return [PHWUnknownStrategy class]; +} + +#endif diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_events.m b/packages/permission_handler_watchos/watchos/Classes/phw_events.m index e397c59..70a08c9 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_events.m +++ b/packages/permission_handler_watchos/watchos/Classes/phw_events.m @@ -5,10 +5,34 @@ // Calendar (full access and write only) and reminders, through // EKEventStore, answered as the iOS 17 implementation answers them. EventKit // is loaded at runtime and only messaged. +// +// The macros decide as the iOS implementation's do. Any of the three below +// compiles this strategy in, or a flutter-watchos that predates `defines` +// does. All four permissions then read their status from EventKit, and the +// service is not applicable. Each macro adds requests: +// - PERMISSION_EVENTS_FULL_ACCESS (NSCalendarsFullAccessUsageDescription): +// the full-access one of calendar and calendarFullAccess, and the +// write-only one of calendarWriteOnly too, as on iOS; +// - PERMISSION_EVENTS (NSCalendarsWriteOnlyAccessUsageDescription): the +// write-only one; +// - PERMISSION_REMINDERS (NSRemindersFullAccessUsageDescription): reminders. +// A request whose prompt is left out answers its status and asks nothing; +// that prompt's key and request are not in the app. With none of the three, +// none of this is, and the four answer as PHWUnknownStrategy. +// +// Each prompt still needs its own key (phw_has_key below), which the iOS +// implementation does not check. So with only the full-access key, +// calendarWriteOnly is granted once full access is, but a request while it +// is not determined fails with MISSING_USAGE_DESCRIPTION, where the iOS +// implementation sends the write-only request. #import "phw_internal.h" +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_EVENTS_FULL_ACCESS || \ + PERMISSION_EVENTS || PERMISSION_REMINDERS + #import +#import static EKEntityType _entityType(PHWPermission permission) { return permission == PHWPermissionReminders ? EKEntityTypeReminder : EKEntityTypeEvent; @@ -33,18 +57,42 @@ static PHWStatus _eventsStatus(PHWPermission permission, NSInteger status) { return PHWStatusDenied; } -// The usage description the prompt for `permission` needs. -static NSString* _usageKey(PHWPermission permission) { +// What asks for one kind of access: the usage description its prompt needs, +// and the EKEventStore request. +typedef struct { + __unsafe_unretained NSString* key; + SEL request; +} PHWEventsKind; + +// The kind `permission` asks for; its key is nil when this build left its +// prompt out. +static PHWEventsKind _kind(PHWPermission permission) { switch (permission) { - case PHWPermissionReminders: - return @"NSRemindersFullAccessUsageDescription"; +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_EVENTS_FULL_ACCESS + case PHWPermissionCalendar: + case PHWPermissionCalendarFullAccess: + return (PHWEventsKind){@"NSCalendarsFullAccessUsageDescription", + @selector(requestFullAccessToEventsWithCompletion:)}; +#endif +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_EVENTS || \ + PERMISSION_EVENTS_FULL_ACCESS case PHWPermissionCalendarWriteOnly: - return @"NSCalendarsWriteOnlyAccessUsageDescription"; + return (PHWEventsKind){@"NSCalendarsWriteOnlyAccessUsageDescription", + @selector(requestWriteOnlyAccessToEventsWithCompletion:)}; +#endif +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_REMINDERS + case PHWPermissionReminders: + return (PHWEventsKind){@"NSRemindersFullAccessUsageDescription", + @selector(requestFullAccessToRemindersWithCompletion:)}; +#endif default: - return @"NSCalendarsFullAccessUsageDescription"; + return (PHWEventsKind){nil, NULL}; } } +@interface PHWEventsStrategy : NSObject +@end + @implementation PHWEventsStrategy { // Kept until its request answers. EKEventStore* _store; @@ -85,9 +133,15 @@ - (void)requestPermission:(PHWPermission)permission done(status); return; } - NSString* key = _usageKey(permission); - if (!phw_has_key(key)) { - phw_missing_key(key, failed); + PHWEventsKind kind = _kind(permission); + if (kind.key == nil) { + // Its prompt is left out: no key the app's Info.plist sets allows it. + // The iOS implementation answers denied here too. + done(PHWStatusDenied); + return; + } + if (!phw_has_key(kind.key)) { + phw_missing_key(kind.key, failed); return; } _store = [[storeClass alloc] init]; @@ -97,17 +151,19 @@ - (void)requestPermission:(PHWPermission)permission done(granted ? PHWStatusGranted : PHWStatusPermanentlyDenied); }); }; - switch (permission) { - case PHWPermissionReminders: - [_store requestFullAccessToRemindersWithCompletion:answer]; - break; - case PHWPermissionCalendarWriteOnly: - [_store requestWriteOnlyAccessToEventsWithCompletion:answer]; - break; - default: - [_store requestFullAccessToEventsWithCompletion:answer]; - break; - } + ((void (*)(id, SEL, void (^)(BOOL, NSError*)))objc_msgSend)(_store, kind.request, answer); } @end + +Class phw_events_strategy(void) { + return [PHWEventsStrategy class]; +} + +#else + +Class phw_events_strategy(void) { + return [PHWUnknownStrategy class]; +} + +#endif diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_internal.h b/packages/permission_handler_watchos/watchos/Classes/phw_internal.h index 12379b4..44beca3 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_internal.h +++ b/packages/permission_handler_watchos/watchos/Classes/phw_internal.h @@ -67,14 +67,18 @@ typedef void (^PHWErrorHandler)(NSString* code, NSString* message); // The class `className` of the system framework `framework`, loaded on first // use, or nil when this watchOS has neither. No framework but // UserNotifications is linked, so an app does not load the frameworks of the -// permissions it never asks about. The class names and selectors are still -// in the binary: unlike the iOS implementation's opt-in macros, this does -// not take a group's code out of the app. +// permissions it never asks about. What keeps a group's class names and +// selectors out of the binary is its macro (see below), not this. PHW_HIDDEN Class phw_class(NSString* framework, NSString* className); // Whether the app's Info.plist has `key`. PHW_HIDDEN BOOL phw_has_key(NSString* key); +// Whether `value`, from the app's Info.plist, says yes: the Boolean YES, or a +// string equal to YES, true or 1, ignoring case. Anything else does not: NO, +// an empty string, any other string, a number (even 1), or no value. +PHW_HIDDEN BOOL phw_says_yes(id value); + // Fails with MISSING_USAGE_DESCRIPTION, naming `key`. PHW_HIDDEN void phw_missing_key(NSString* key, PHWErrorHandler failed); @@ -87,40 +91,60 @@ PHW_HIDDEN NSInteger phw_class_integer_with(Class cls, SEL selector, NSInteger a // Sends a class method with no argument that returns a BOOL. PHW_HIDDEN BOOL phw_class_bool(Class cls, SEL selector); -// What answers for the permissions watchOS does not have: as the iOS -// implementation answers for an unknown permission. +// What answers for the permissions watchOS does not have, and for a group +// left out of the build: as the iOS implementation answers for an unknown +// permission. @interface PHWUnknownStrategy : NSObject @end +// Notifications and critical alerts need no usage description, so they are +// always compiled in. @interface PHWNotificationStrategy : NSObject @end -@interface PHWLocationStrategy : NSObject +@interface PHWCriticalAlertsStrategy : NSObject @end -@interface PHWMotionStrategy : NSObject +// Storage, granted as on iOS: the app's own container needs no permission. +@interface PHWStorageStrategy : NSObject @end -@interface PHWMicrophoneStrategy : NSObject -@end +// ---- Groups the app's Info.plist chooses ------------------------------------ +// +// Each group below is compiled in only when the app's Info.plist sets one of +// its keys: flutter-watchos then defines the group's macro, as `defines` in +// pubspec.yaml maps them, along with FLUTTER_WATCHOS_PLUGIN_DEFINES. A group +// left out is not in the binary at all, its framework's class names, +// selectors and usage-description keys included, and answers as +// PHWUnknownStrategy, as the iOS implementation's groups do when their macros +// are off. A flutter-watchos that predates `defines`, or that cannot read the +// app's Info.plist, passes neither, so every group is compiled in, as before. +// +// Each function returns the strategy class: the group's own, or +// PHWUnknownStrategy when the group (for location, the permission) is left +// out. The group's file defines it, next to the code it guards, so the two +// cannot disagree. -@interface PHWContactsStrategy : NSObject -@end +// location, locationWhenInUse and locationAlways (phw_location.m). +PHW_HIDDEN Class phw_location_strategy(PHWPermission permission); -@interface PHWEventsStrategy : NSObject -@end +// sensors (phw_motion.m). +PHW_HIDDEN Class phw_motion_strategy(void); -@interface PHWBluetoothStrategy : NSObject -@end +// microphone (phw_microphone.m). +PHW_HIDDEN Class phw_microphone_strategy(void); -// Storage, granted as on iOS: the app's own container needs no permission. -@interface PHWStorageStrategy : NSObject -@end +// contacts (phw_contacts.m). +PHW_HIDDEN Class phw_contacts_strategy(void); -@interface PHWCriticalAlertsStrategy : NSObject -@end +// calendar, calendarFullAccess, calendarWriteOnly and reminders, all four +// together (phw_events.m). +PHW_HIDDEN Class phw_events_strategy(void); -@interface PHWAssistantStrategy : NSObject -@end +// bluetooth (phw_bluetooth.m). +PHW_HIDDEN Class phw_bluetooth_strategy(void); + +// assistant (phw_assistant.m). +PHW_HIDDEN Class phw_assistant_strategy(void); #endif // PHW_INTERNAL_H diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_location.m b/packages/permission_handler_watchos/watchos/Classes/phw_location.m index 2050a87..1fb5640 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_location.m +++ b/packages/permission_handler_watchos/watchos/Classes/phw_location.m @@ -8,15 +8,31 @@ // CoreLocation is loaded at runtime and only messaged: no class of it is // referenced by symbol, so an app that never asks for location links none // of it. +// +// Compiled in only when the app's Info.plist sets a location usage +// description, or by a flutter-watchos that predates `defines`: +// - NSLocationWhenInUseUsageDescription (PERMISSION_LOCATION_WHENINUSE) +// compiles in location and locationWhenInUse; +// - NSLocationAlwaysAndWhenInUseUsageDescription or +// NSLocationAlwaysUsageDescription (PERMISSION_LOCATION) compiles in +// locationAlways too. +// A permission left out answers as PHWUnknownStrategy. Without "always", its +// request and the NSUserDefaults flag below are not in the app; with neither +// key, none of this is. #import "phw_internal.h" +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_LOCATION || \ + PERMISSION_LOCATION_WHENINUSE + #import #import +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_LOCATION // Set once "always" was asked for: watchOS shows that prompt once, and a // second request must answer without waiting for a prompt that never comes. static NSString* const kAskedAlwaysKey = @"dev.flutterwatch.permission_handler.askedAlways"; +#endif // How long after the app becomes active a status still "not determined" // waits for the delegate before it counts as a prompt closed unanswered. @@ -39,7 +55,7 @@ static PHWStatus _locationStatus(PHWPermission permission, CLAuthorizationStatus return PHWStatusDenied; } -@interface PHWLocationStrategy () +@interface PHWLocationStrategy : NSObject @end @implementation PHWLocationStrategy { @@ -96,10 +112,12 @@ - (void)requestPermission:(PHWPermission)permission return; } CLAuthorizationStatus status = manager.authorizationStatus; +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_LOCATION if (permission == PHWPermissionLocationAlways) { [self requestAlways:status done:done failed:failed]; return; } +#endif if (status != kCLAuthorizationStatusNotDetermined) { done(_locationStatus(permission, status)); return; @@ -118,6 +136,7 @@ - (void)requestPermission:(PHWPermission)permission [manager requestWhenInUseAuthorization]; } +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_LOCATION - (void)requestAlways:(CLAuthorizationStatus)status done:(PHWStatusHandler)done failed:(PHWErrorHandler)failed { @@ -146,6 +165,7 @@ - (void)requestAlways:(CLAuthorizationStatus)status [self observeActive]; [_manager requestAlwaysAuthorization]; } +#endif // Answers the waiting requests when the app becomes active again after the // prompt took the screen (it resigned active first), with what the status is @@ -228,3 +248,22 @@ - (void)locationManagerDidChangeAuthorization:(CLLocationManager*)manager { } @end + +Class phw_location_strategy(PHWPermission permission) { +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_LOCATION + return [PHWLocationStrategy class]; +#else + // "When in use" alone: "always" answers as a permission watchOS does not + // have. + return permission == PHWPermissionLocationAlways ? [PHWUnknownStrategy class] + : [PHWLocationStrategy class]; +#endif +} + +#else + +Class phw_location_strategy(PHWPermission permission) { + return [PHWUnknownStrategy class]; +} + +#endif diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_microphone.m b/packages/permission_handler_watchos/watchos/Classes/phw_microphone.m index 2e2b526..56b2a47 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_microphone.m +++ b/packages/permission_handler_watchos/watchos/Classes/phw_microphone.m @@ -4,9 +4,16 @@ // // The microphone, through AVAudioApplication's record permission. AVFAudio // is loaded at runtime and only messaged. +// +// Compiled in only when the app's Info.plist sets +// NSMicrophoneUsageDescription (PERMISSION_MICROPHONE), or by a +// flutter-watchos that predates `defines`. Otherwise the microphone answers +// as PHWUnknownStrategy, and none of this is in the app. #import "phw_internal.h" +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_MICROPHONE + #import #import @@ -22,6 +29,9 @@ static PHWStatus _recordStatus(NSInteger permission) { return PHWStatusDenied; } +@interface PHWMicrophoneStrategy : NSObject +@end + @implementation PHWMicrophoneStrategy - (Class)applicationClass { @@ -70,3 +80,15 @@ - (void)requestPermission:(PHWPermission)permission } @end + +Class phw_microphone_strategy(void) { + return [PHWMicrophoneStrategy class]; +} + +#else + +Class phw_microphone_strategy(void) { + return [PHWUnknownStrategy class]; +} + +#endif diff --git a/packages/permission_handler_watchos/watchos/Classes/phw_motion.m b/packages/permission_handler_watchos/watchos/Classes/phw_motion.m index f79f40e..d459780 100644 --- a/packages/permission_handler_watchos/watchos/Classes/phw_motion.m +++ b/packages/permission_handler_watchos/watchos/Classes/phw_motion.m @@ -5,9 +5,16 @@ // Sensors: Motion & Fitness access, through CMMotionActivityManager, asked // for with a query as the iOS implementation asks. CoreMotion is loaded at // runtime and only messaged. +// +// Compiled in only when the app's Info.plist sets NSMotionUsageDescription +// (PERMISSION_SENSORS), or by a flutter-watchos that predates `defines`. +// Otherwise sensors answers as PHWUnknownStrategy, and none of this is in +// the app. #import "phw_internal.h" +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || PERMISSION_SENSORS + #import static PHWStatus _motionStatus(NSInteger status) { @@ -24,6 +31,9 @@ static PHWStatus _motionStatus(NSInteger status) { return PHWStatusGranted; } +@interface PHWMotionStrategy : NSObject +@end + @implementation PHWMotionStrategy { // Kept until its query answers. CMMotionActivityManager* _manager; @@ -80,3 +90,15 @@ - (void)requestPermission:(PHWPermission)permission } @end + +Class phw_motion_strategy(void) { + return [PHWMotionStrategy class]; +} + +#else + +Class phw_motion_strategy(void) { + return [PHWUnknownStrategy class]; +} + +#endif diff --git a/tool/README.md b/tool/README.md index 6d4412e..f7366a1 100644 --- a/tool/README.md +++ b/tool/README.md @@ -19,7 +19,8 @@ tool/check_generated_ignored.sh ``` The `native` job of `ci.yml` runs one more on macOS, once per Xcode, as -`DEVELOPER_DIR` picks it. It needs Xcode, so the Ubuntu jobs leave it out: +`DEVELOPER_DIR` picks it. It needs Xcode, so the Ubuntu jobs leave it out. +It also runs `dart`, after the `dart pub get` above: ```sh tool/check_native_sources.sh @@ -132,6 +133,28 @@ a SwiftPM build provides its modules. `--list` prints the plan without compiling, and the Ubuntu tool tests run it on fixtures. The compile fixtures run on macOS, in the `native` job. +A package that declares `defines` in its pubspec (macros that the app's +Info.plist turns on, from the first flutter-watchos release after 0.1.1) is +compiled several times per target, so each guard compiles with its macros +all on, all off and one at a time, nested guards included: +- with no `-D`, as an older CLI compiles it; +- with `FLUTTER_WATCHOS_PLUGIN_DEFINES` and every macro, as for an app that + sets every key; +- with the marker alone, as for an app that sets none; +- with more than one macro, with the marker and each macro alone, as for an + app that sets one group's keys. + +clang gets each define as `NAME=1` and swiftc as a bare `NAME`, as the CLI +passes them. `bin/plugin_defines.dart` reads the pubspecs for the script, +with package:yaml, the parser the CLI uses. A YAML reader in shell would miss +what YAML allows (flow maps, quoting, comments), and the system Ruby and +Python read YAML 1.1, where a bare `NO` is false. A declaration the CLI would +refuse (`defines` not a map, a macro that is not a C identifier, a key that +is not a non-empty string) fails before anything is compiled. The fixtures +include guards that are wrong only for an app without the key, in C and in +Swift, which must fail in the marker-alone pass, and a nested guard that is +wrong only for one macro alone, which must fail in that pass. + ## Weekly upstream compatibility `.github/workflows/upstream.yml` runs every Monday and by hand, and never diff --git a/tool/bin/plugin_defines.dart b/tool/bin/plugin_defines.dart new file mode 100644 index 0000000..f917c5b --- /dev/null +++ b/tool/bin/plugin_defines.dart @@ -0,0 +1,26 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Prints the macros each package declares under +/// `flutter.plugin.platforms.watchos.defines`, one line per package that +/// declares any: `defines …`, in the order +/// declared. Fails, naming the package and each problem, on a declaration +/// the CLI would refuse. `check_native_sources.sh` runs it. +/// +/// Usage, from `tool/`: `dart run bin/plugin_defines.dart +/// [--root ]`. +library; + +import 'dart:io'; + +import 'package:plugins_tool/src/plugin_defines.dart'; +import 'package:plugins_tool/src/repository.dart'; + +void main(List arguments) { + final Map options = parseOptions(arguments, {'root'}); + exitCode = runPluginDefines( + options['root'] ?? repositoryRootOf(toolDirectory()), + stdout, + ); +} diff --git a/tool/check_native_sources.sh b/tool/check_native_sources.sh index 88995ab..740064d 100755 --- a/tool/check_native_sources.sh +++ b/tool/check_native_sources.sh @@ -18,6 +18,22 @@ # - A plugin whose watchos/Package.swift names a SwiftPM package is skipped # and listed: its sources import that package's modules, which only a # SwiftPM build provides, and the CLI builds such a plugin with xcodebuild. +# - A plugin that declares `defines` in its pubspec (macros the app's +# Info.plist turns on, from the first flutter-watchos release after 0.1.1) +# is compiled with no -D, as an older CLI compiles it; with +# FLUTTER_WATCHOS_PLUGIN_DEFINES and every macro, as for an app that sets +# every key; with the marker alone, as for an app that sets none; and, when +# it declares more than one, with the marker and each macro alone, as for +# an app that sets one group's keys. So each guard compiles with its macros +# all on, all off and one at a time, nested guards included. clang gets +# each define as NAME=1, and swiftc a bare NAME, as the CLI passes them. +# +# The pubspecs are read by tool/bin/plugin_defines.dart, with package:yaml, +# the parser the CLI uses, so dart must be on PATH and `dart pub get` must +# have run in tool/. A YAML reader in shell would miss what YAML allows (flow +# maps and lists, quoting, comments), and the system Ruby and Python read +# YAML 1.1, where a bare NO is false. A declaration the CLI would refuse +# fails here before anything is compiled. # # Only git-tracked files are read. A warning fails like an error, as in the # CLI's own host typecheck, except a deprecation: those are counted and left @@ -30,6 +46,9 @@ set -euo pipefail # The deployment target `flutter-watchos create` writes. readonly deployment_target="26.0" readonly targets="watchos:arm64-apple-watchos${deployment_target} watchsimulator:arm64-apple-watchos${deployment_target}-simulator" +# The define the CLI passes to every source of a plugin that declares +# `defines`, with the macros the app's Info.plist turns on. +readonly marker="FLUTTER_WATCHOS_PLUGIN_DEFINES" root="" list=0 @@ -43,7 +62,11 @@ done if [ -z "$root" ]; then root="$(git rev-parse --show-toplevel)" fi -shim="$(cd "$(dirname "$0")" && pwd)/native/plugin_views_shim.swift" +# Absolute, as the script moves into it and then hands it to the Dart tool, +# which would read a relative root from inside itself. +root="$(cd "$root" 2> /dev/null && pwd)" || { echo "--root: no directory there" >&2; exit 2; } +tool="$(cd "$(dirname "$0")" && pwd)" +shim="$tool/native/plugin_views_shim.swift" cd "$root" # The tracked files under each package's own watchos/ directory. In a @@ -58,6 +81,38 @@ for dir in $(printf '%s\n' "$tracked" | grep '\.h$' | xargs -n1 dirname | sort - includes+=("-I$dir") done +# One line per package that declares `defines`: +# "defines …". +defines="" +if [ -n "$(git ls-files -- 'packages/*/pubspec.yaml')" ]; then + if ! command -v dart > /dev/null; then + echo "dart is not on PATH: tool/bin/plugin_defines.dart reads each pubspec's defines." >&2 + exit 2 + fi + code=0 + defines="$(dart "$tool/bin/plugin_defines.dart" --root "$root")" || code=$? + case "$code" in + 0) ;; + 1) + echo "$defines" + echo "Native sources: a plugin's defines are invalid, so nothing was compiled." + exit 1 + ;; + *) + echo "tool/bin/plugin_defines.dart did not run; has \`dart pub get\` run in tool/?" >&2 + exit 2 + ;; + esac +fi + +# Prints the macros the package [1] declares, space-separated. +macros_of() { + printf '%s\n' "$defines" | awk -v package="$1" ' + $1 == "defines" && $2 == package { + for (i = 3; i <= NF; i++) printf "%s%s", $i, (i < NF ? " " : "") + }' +} + # True for a view source: not the manifest, and nothing under Flutter/ or a # dot-directory (CLI build output and SwiftPM state). is_view_source() { @@ -115,32 +170,82 @@ for package_dir in $(printf '%s\n' "$tracked" | cut -d/ -f1-2 | sort -u); do *) if is_view_source "$file"; then view_sources+=("$file"); fi ;; esac done + # Pass 0 compiles with no -D. A package with defines also gets pass 1, the + # marker and every macro, pass 2, the marker alone, and, with more than one + # macro, a pass for each: the marker and that macro alone. + macros="$(macros_of "$package_dir")" + macro_list=() + for macro in $macros; do + macro_list+=("$macro") + done + passes=1 + if [ ${#macro_list[@]} -gt 0 ]; then + passes=3 + if [ ${#macro_list[@]} -gt 1 ]; then + passes=$((3 + ${#macro_list[@]})) + fi + echo "defines $package: $macros" + fi for target in $targets; do sdk="${target%%:*}" triple="${target#*:}" - for source in ${c_sources[@]+"${c_sources[@]}"}; do - checked=$((checked + 1)) - if [ "$list" -eq 1 ]; then - echo "plan clang $sdk $triple $source" - continue + pass=0 + while [ "$pass" -lt "$passes" ]; do + clang_defines=() + swift_defines=() + if [ "$pass" -gt 0 ]; then + clang_defines=("-D$marker=1") + swift_defines=("-D$marker") fi - code=0 - output="$(xcrun -sdk "$sdk" clang -fsyntax-only -target "$triple" \ - -fobjc-arc -fmodules ${includes[@]+"${includes[@]}"} "$source" 2>&1)" || code=$? - report "$source" "$code" "$output" - done - if [ ${#view_sources[@]} -gt 0 ]; then - checked=$((checked + 1)) - if [ "$list" -eq 1 ]; then - echo "plan swiftc $sdk $triple ${package}_views: ${view_sources[*]}" - continue + if [ "$pass" -eq 1 ]; then + for macro in "${macro_list[@]}"; do + clang_defines+=("-D$macro=1") + swift_defines+=("-D$macro") + done + elif [ "$pass" -ge 3 ]; then + macro="${macro_list[$((pass - 3))]}" + clang_defines+=("-D$macro=1") + swift_defines+=("-D$macro") fi - code=0 - output="$(xcrun -sdk "$sdk" swiftc -typecheck -target "$triple" \ - -parse-as-library -module-name "${package}_views" \ - "$shim" "${view_sources[@]}" 2>&1)" || code=$? - report "${package}_views" "$code" "$output" - fi + # The flags as --list shows them. + clang_flags="${clang_defines[*]+${clang_defines[*]}}" + swift_flags="${swift_defines[*]+${swift_defines[*]}}" + # Which pass a line is about, for a package with defines. + label="" + if [ "$passes" -gt 1 ]; then + case "$pass" in + 0) label=" [no -D]" ;; + 1) label=" [marker, every macro]" ;; + 2) label=" [marker alone]" ;; + *) label=" [marker, ${macro_list[$((pass - 3))]} alone]" ;; + esac + fi + for source in ${c_sources[@]+"${c_sources[@]}"}; do + checked=$((checked + 1)) + if [ "$list" -eq 1 ]; then + echo "plan clang $sdk $triple $source$label${clang_flags:+ $clang_flags}" + continue + fi + code=0 + output="$(xcrun -sdk "$sdk" clang -fsyntax-only -target "$triple" \ + -fobjc-arc -fmodules ${clang_defines[@]+"${clang_defines[@]}"} \ + ${includes[@]+"${includes[@]}"} "$source" 2>&1)" || code=$? + report "$source$label" "$code" "$output" + done + if [ ${#view_sources[@]} -gt 0 ]; then + checked=$((checked + 1)) + if [ "$list" -eq 1 ]; then + echo "plan swiftc $sdk $triple ${package}_views$label${swift_flags:+ $swift_flags}: ${view_sources[*]}" + else + code=0 + output="$(xcrun -sdk "$sdk" swiftc -typecheck -target "$triple" \ + -parse-as-library ${swift_defines[@]+"${swift_defines[@]}"} \ + -module-name "${package}_views" "$shim" "${view_sources[@]}" 2>&1)" || code=$? + report "${package}_views$label" "$code" "$output" + fi + fi + pass=$((pass + 1)) + done done done diff --git a/tool/lib/src/plugin_defines.dart b/tool/lib/src/plugin_defines.dart new file mode 100644 index 0000000..fd5e5db --- /dev/null +++ b/tool/lib/src/plugin_defines.dart @@ -0,0 +1,171 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The per-app defines each plugin declares, for `check_native_sources.sh`. +/// +/// A plugin maps preprocessor macros to app Info.plist keys under +/// `flutter.plugin.platforms.watchos.defines` in its pubspec. From the first +/// flutter-watchos release after 0.1.1, the CLI compiles the plugin's native +/// sources with `FLUTTER_WATCHOS_PLUGIN_DEFINES` and each macro whose key the +/// app's Info.plist sets; an older CLI passes neither, and nor does the CLI +/// for a plugin that declares none. So the check compiles such a plugin with +/// no `-D`, with the marker and every macro, with the marker alone, and with +/// the marker and each macro alone. +/// +/// The pubspec is read with package:yaml, the parser the CLI reads it with, +/// so a declaration means the same to both, and a declaration the CLI would +/// refuse fails here with the CLI's words: `defines` must be a map, each +/// macro a C identifier, and each value an Info.plist key (a non-empty +/// string) or a non-empty list of keys. +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'repository.dart'; + +/// A C identifier, which a macro name must be. +final RegExp _cIdentifier = RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$'); + +/// A package's pubspec, one level under `packages/`. +final RegExp _packagePubspec = RegExp(r'^packages/[^/]+/pubspec\.yaml$'); + +/// What a pubspec declares under `flutter.plugin.platforms.watchos.defines`. +class PluginDefines { + /// Creates the declaration of [macros], with the [problems] that make the + /// CLI refuse it. + const PluginDefines(this.macros, this.problems); + + /// The macro names, in the order the pubspec declares them. + final List macros; + + /// What is wrong with the declaration, as the CLI words it. Empty when the + /// CLI accepts it. + final List problems; +} + +/// Reads the `defines` of the pubspec text [pubspec]. +/// +/// A pubspec without `defines`, or with an empty one, declares none. +PluginDefines readPluginDefines(String pubspec) { + Object? node; + try { + node = loadYaml(pubspec); + } on YamlException catch (error) { + return PluginDefines(const [], [ + 'pubspec.yaml does not parse: ${error.message}', + ]); + } + for (final String key in [ + 'flutter', + 'plugin', + 'platforms', + 'watchos', + 'defines', + ]) { + node = node is YamlMap ? node[key] : null; + } + if (node == null) { + return const PluginDefines([], []); + } + if (node is! YamlMap) { + return PluginDefines(const [], [ + '`defines` is ${_describe(node)}, not a map.', + ]); + } + final List macros = []; + final List problems = []; + for (final MapEntry entry in node.entries) { + final Object? macro = entry.key; + if (macro is! String || !_cIdentifier.hasMatch(macro)) { + problems.add('`$macro` is not a C identifier.'); + continue; + } + final List keyProblems = _keysProblems(macro, entry.value); + if (keyProblems.isEmpty) { + macros.add(macro); + } else { + problems.addAll(keyProblems); + } + } + return PluginDefines(macros, problems); +} + +/// What is wrong with [value] as the Info.plist keys of [macro]: empty for +/// a key or a non-empty list of keys. +List _keysProblems(String macro, Object? value) { + if (value == null) { + return ['`$macro` names no Info.plist key.']; + } + if (value is String) { + return value.isEmpty + ? ['`$macro` names an empty Info.plist key.'] + : const []; + } + if (value is! YamlList) { + return [ + '`$macro` maps to ${_describe(value)}, not an Info.plist key or a list ' + 'of keys.', + ]; + } + if (value.isEmpty) { + return ['`$macro` lists no Info.plist key.']; + } + return [ + for (final Object? key in value) + if (key is! String || key.isEmpty) + '`$macro` lists ${_describe(key)}, which is not an Info.plist key.', + ]; +} + +/// [value] as a message shows it. +String _describe(Object? value) { + return switch (value) { + null => 'an empty entry', + String() when value.isEmpty => 'an empty string', + String() => '"$value"', + YamlMap() => 'a map', + YamlList() => 'a list', + _ => '$value', + }; +} + +/// Writes to [out] a line `defines …` for each +/// package of the repository at [root] whose tracked pubspec declares +/// `defines`, and the problems of each declaration the CLI would refuse. +/// +/// Returns 0, or 1 when a declaration is refused. +int runPluginDefines(String root, StringSink out) { + final List pubspecs = git(root, [ + 'ls-files', + '--', + 'packages/*/pubspec.yaml', + ]).split('\n').where(_packagePubspec.hasMatch).toList()..sort(); + int status = 0; + for (final String pubspec in pubspecs) { + final String package = p.dirname(pubspec); + final PluginDefines defines = readPluginDefines( + File(p.join(root, pubspec)).readAsStringSync(), + ); + if (defines.problems.isNotEmpty) { + out.writeln( + 'The watchOS plugin ${p.basename(package)} has an invalid `defines` ' + 'in $pubspec:', + ); + for (final String problem in defines.problems) { + out.writeln(' - $problem'); + } + out.writeln( + 'Each entry maps a macro name, a C identifier, to an app Info.plist ' + 'key or to a list of keys.', + ); + status = 1; + } else if (defines.macros.isNotEmpty) { + out.writeln('defines $package ${defines.macros.join(' ')}'); + } + } + return status; +} diff --git a/tool/test/check_native_sources_test.dart b/tool/test/check_native_sources_test.dart index 36a7f10..3022b7a 100644 --- a/tool/test/check_native_sources_test.dart +++ b/tool/test/check_native_sources_test.dart @@ -33,6 +33,13 @@ void main() { ]); } + /// The lines of [result] that report one compile, with their [prefix]. + List reports(ProcessResult result, String prefix) => + '${result.stdout}' + .split('\n') + .where((String l) => l.startsWith(prefix)) + .toList(); + /// Checks [result] against [fixture]'s `expect` file. void expectFixture(Fixture fixture, ProcessResult result) { final String output = '${result.stdout}${result.stderr}'; @@ -73,6 +80,56 @@ void main() { final Fixture fixture = named('no_sources'); expectFixture(fixture, run(fixture, ['--list'])); }); + + // A plugin's `defines` turn its macros on per app, so its sources are + // compiled with no -D (an older CLI), with the marker and every macro, + // with the marker alone, and with the marker and each macro alone. A + // plugin without them is compiled once. + test( + 'plans a pass per macro set for a plugin with defines, one for the rest', + () { + final Fixture fixture = named('plan_defines'); + final ProcessResult result = run(fixture, ['--list']); + expectFixture(fixture, result); + final List lines = '${result.stdout}'.split('\n'); + expect( + lines.where((String l) => l.contains('d_watchos_ffi.m')), + hasLength(10), + reason: 'five passes, for a watch and the Simulator', + ); + expect( + lines.where((String l) => l.contains('e_watchos_ffi.m')), + [ + 'plan clang watchos arm64-apple-watchos26.0 ' + 'packages/e_watchos/watchos/Classes/e_watchos_ffi.m', + 'plan clang watchsimulator arm64-apple-watchos26.0-simulator ' + 'packages/e_watchos/watchos/Classes/e_watchos_ffi.m', + ], + ); + }, + ); + + test('a declaration the CLI refuses fails before anything is planned', () { + final Fixture fixture = named('defines_invalid'); + final ProcessResult result = run(fixture, ['--list']); + expectFixture(fixture, result); + expect('${result.stdout}', isNot(contains('plan '))); + }); + + // The script moves into the root and hands it to the Dart tool, which + // must not read a relative root from inside it a second time. + test('a relative --root plans the same as an absolute one', () { + final Fixture fixture = named('plan_defines'); + final String root = fixture.createRepository(); + addTearDown(() => Directory(root).deleteSync(recursive: true)); + final ProcessResult result = Process.runSync('bash', [ + script, + '--root', + p.basename(root), + '--list', + ], workingDirectory: p.dirname(root)); + expectFixture(fixture, result); + }); }); group('compiles', () { @@ -90,5 +147,69 @@ void main() { final Fixture fixture = named('compile_warning'); expectFixture(fixture, run(fixture, [])); }); + + test('guards right on both sides pass in every pass', () { + final Fixture fixture = named('compile_defines'); + expectFixture(fixture, run(fixture, [])); + }); + + // The guarded function is called from outside its guard: that compiles + // with no -D and with every macro, and fails for an app without the key. + test( + 'a guard wrong only without its macro fails in the marker-alone pass', + () { + final Fixture fixture = named('compile_defines_guard'); + final ProcessResult result = run(fixture, []); + expectFixture(fixture, result); + final List lines = '${result.stdout}'.split('\n'); + final List failed = lines + .where((String l) => l.startsWith('FAILED')) + .toList(); + expect(failed, hasLength(2), reason: 'a watch and the Simulator'); + expect(failed, everyElement(endsWith('[marker alone]'))); + expect( + lines.where((String l) => l.startsWith('ok ')), + hasLength(4), + reason: 'no -D, and the marker with every macro, for each target', + ); + }, + ); + + // An inner guard inside an outer one that two macros open: only the + // marker with the other macro alone compiles the outer block without + // the inner one. + test( + 'a nested guard wrong only for one macro alone fails in that pass', + () { + final Fixture fixture = named('compile_defines_partial'); + final ProcessResult result = run(fixture, []); + expectFixture(fixture, result); + final List failed = reports(result, 'FAILED'); + expect(failed, hasLength(2), reason: 'a watch and the Simulator'); + expect(failed, everyElement(endsWith('[marker, GP_EXTRA alone]'))); + expect( + reports(result, 'ok '), + hasLength(8), + reason: 'the four other passes, for each target', + ); + }, + ); + + // swiftc must get the defines as conditions: without them, the + // marker-alone pass would compile the guarded value in and pass. + test('a Swift guard wrong only without its macro fails in the marker-alone ' + 'pass', () { + final Fixture fixture = named('compile_defines_swift_guard'); + final ProcessResult result = run(fixture, []); + expectFixture(fixture, result); + final List failed = reports(result, 'FAILED'); + expect(failed, hasLength(2), reason: 'a watch and the Simulator'); + expect(failed, everyElement(endsWith('gs_watchos_views [marker alone]'))); + expect( + reports(result, 'ok '), + hasLength(4), + reason: 'no -D, and the marker with every macro, for each target', + ); + }); }, skip: Platform.isMacOS ? false : 'needs Xcode'); } diff --git a/tool/test/fixtures/check_native_sources/compile_defines/expect b/tool/test/fixtures/check_native_sources/compile_defines/expect new file mode 100644 index 0000000..0bc189a --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines/expect @@ -0,0 +1,13 @@ +0 +defines gd_watchos: GD_CAMERA GD_EXTRA +packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m [no -D] +packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m [marker, every macro] +packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m [marker alone] +packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m [marker, GD_CAMERA alone] +packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m [marker, GD_EXTRA alone] +gd_watchos_views [no -D] +gd_watchos_views [marker, every macro] +gd_watchos_views [marker alone] +gd_watchos_views [marker, GD_CAMERA alone] +gd_watchos_views [marker, GD_EXTRA alone] +Native sources: all compile cleanly with diff --git a/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/pubspec.yaml b/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/pubspec.yaml new file mode 100644 index 0000000..ffc5c7c --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/pubspec.yaml @@ -0,0 +1,10 @@ +name: gd_watchos +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true + defines: + GD_CAMERA: NSCameraUsageDescription + GD_EXTRA: + - GdExtra diff --git a/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m b/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m new file mode 100644 index 0000000..da0a684 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/watchos/Classes/gd_watchos_ffi.m @@ -0,0 +1,9 @@ +#import + +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || GD_CAMERA +static int gd_watchos_camera(void) { return 1; } +#else +static int gd_watchos_camera(void) { return 0; } +#endif + +int gd_watchos_value(void) { return gd_watchos_camera(); } diff --git a/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/watchos/Views/gd_watchos_views.swift b/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/watchos/Views/gd_watchos_views.swift new file mode 100644 index 0000000..4e95ce0 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines/repo/packages/gd_watchos/watchos/Views/gd_watchos_views.swift @@ -0,0 +1,14 @@ +import SwiftUI + +#if !FLUTTER_WATCHOS_PLUGIN_DEFINES || GD_CAMERA +let gdWatchosCamera = true +#else +let gdWatchosCamera = false +#endif + +@_cdecl("gd_watchos_register_views") +public func gdWatchosRegisterViews() { + FlutterWatchOSPluginViews.register("gd_watchos") { params in + AnyView(Text(gdWatchosCamera ? params : "")) + } +} diff --git a/tool/test/fixtures/check_native_sources/compile_defines_guard/expect b/tool/test/fixtures/check_native_sources/compile_defines_guard/expect new file mode 100644 index 0000000..497200d --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_guard/expect @@ -0,0 +1,4 @@ +1 +FAILED +packages/gg_watchos/watchos/Classes/gg_watchos_ffi.m [marker alone] +Native sources: some do not compile cleanly with diff --git a/tool/test/fixtures/check_native_sources/compile_defines_guard/repo/packages/gg_watchos/pubspec.yaml b/tool/test/fixtures/check_native_sources/compile_defines_guard/repo/packages/gg_watchos/pubspec.yaml new file mode 100644 index 0000000..93da22d --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_guard/repo/packages/gg_watchos/pubspec.yaml @@ -0,0 +1,8 @@ +name: gg_watchos +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true + defines: + GG_CAMERA: NSCameraUsageDescription diff --git a/tool/test/fixtures/check_native_sources/compile_defines_guard/repo/packages/gg_watchos/watchos/Classes/gg_watchos_ffi.m b/tool/test/fixtures/check_native_sources/compile_defines_guard/repo/packages/gg_watchos/watchos/Classes/gg_watchos_ffi.m new file mode 100644 index 0000000..0280eaa --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_guard/repo/packages/gg_watchos/watchos/Classes/gg_watchos_ffi.m @@ -0,0 +1,9 @@ +#import + +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || GG_CAMERA +static int gg_watchos_camera(void) { return 1; } +#endif + +// Outside the guard of what it calls: fine while the guarded code is +// compiled in, an error for an app whose Info.plist does not set the key. +int gg_watchos_value(void) { return gg_watchos_camera(); } diff --git a/tool/test/fixtures/check_native_sources/compile_defines_partial/expect b/tool/test/fixtures/check_native_sources/compile_defines_partial/expect new file mode 100644 index 0000000..16ac15c --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_partial/expect @@ -0,0 +1,4 @@ +1 +FAILED +packages/gp_watchos/watchos/Classes/gp_watchos_ffi.m [marker, GP_EXTRA alone] +Native sources: some do not compile cleanly with diff --git a/tool/test/fixtures/check_native_sources/compile_defines_partial/repo/packages/gp_watchos/pubspec.yaml b/tool/test/fixtures/check_native_sources/compile_defines_partial/repo/packages/gp_watchos/pubspec.yaml new file mode 100644 index 0000000..2516f14 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_partial/repo/packages/gp_watchos/pubspec.yaml @@ -0,0 +1,9 @@ +name: gp_watchos +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true + defines: + GP_CAMERA: NSCameraUsageDescription + GP_EXTRA: GpExtra diff --git a/tool/test/fixtures/check_native_sources/compile_defines_partial/repo/packages/gp_watchos/watchos/Classes/gp_watchos_ffi.m b/tool/test/fixtures/check_native_sources/compile_defines_partial/repo/packages/gp_watchos/watchos/Classes/gp_watchos_ffi.m new file mode 100644 index 0000000..05202f5 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_partial/repo/packages/gp_watchos/watchos/Classes/gp_watchos_ffi.m @@ -0,0 +1,14 @@ +#import + +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || GP_CAMERA || GP_EXTRA + +#if !defined(FLUTTER_WATCHOS_PLUGIN_DEFINES) || GP_CAMERA +static int gp_watchos_camera(void) { return 1; } +#endif + +// Needs GP_CAMERA's code, but compiles under GP_EXTRA alone too: fine with +// every macro, with none and with GP_CAMERA alone, an error for an app that +// sets only GP_EXTRA's key. +int gp_watchos_value(void) { return gp_watchos_camera(); } + +#endif diff --git a/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/expect b/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/expect new file mode 100644 index 0000000..af31c20 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/expect @@ -0,0 +1,4 @@ +1 +FAILED +gs_watchos_views [marker alone] +Native sources: some do not compile cleanly with diff --git a/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/repo/packages/gs_watchos/pubspec.yaml b/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/repo/packages/gs_watchos/pubspec.yaml new file mode 100644 index 0000000..17d2499 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/repo/packages/gs_watchos/pubspec.yaml @@ -0,0 +1,8 @@ +name: gs_watchos +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true + defines: + GS_CAMERA: NSCameraUsageDescription diff --git a/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/repo/packages/gs_watchos/watchos/Views/gs_watchos_views.swift b/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/repo/packages/gs_watchos/watchos/Views/gs_watchos_views.swift new file mode 100644 index 0000000..fc7a5fc --- /dev/null +++ b/tool/test/fixtures/check_native_sources/compile_defines_swift_guard/repo/packages/gs_watchos/watchos/Views/gs_watchos_views.swift @@ -0,0 +1,14 @@ +import SwiftUI + +#if !FLUTTER_WATCHOS_PLUGIN_DEFINES || GS_CAMERA +let gsWatchosCamera = true +#endif + +// Outside the guard of what it uses: fine while the guarded code is +// compiled in, an error for an app whose Info.plist does not set the key. +@_cdecl("gs_watchos_register_views") +public func gsWatchosRegisterViews() { + FlutterWatchOSPluginViews.register("gs_watchos") { params in + AnyView(Text(gsWatchosCamera ? params : "")) + } +} diff --git a/tool/test/fixtures/check_native_sources/defines_invalid/expect b/tool/test/fixtures/check_native_sources/defines_invalid/expect new file mode 100644 index 0000000..0f7530c --- /dev/null +++ b/tool/test/fixtures/check_native_sources/defines_invalid/expect @@ -0,0 +1,6 @@ +1 +The watchOS plugin bad_watchos has an invalid `defines` in packages/bad_watchos/pubspec.yaml: + - `1BAD_CAMERA` is not a C identifier. + - `BAD_EXTRA` lists no Info.plist key. +Each entry maps a macro name, a C identifier, to an app Info.plist key or to a list of keys. +Native sources: a plugin's defines are invalid, so nothing was compiled. diff --git a/tool/test/fixtures/check_native_sources/defines_invalid/repo/packages/bad_watchos/pubspec.yaml b/tool/test/fixtures/check_native_sources/defines_invalid/repo/packages/bad_watchos/pubspec.yaml new file mode 100644 index 0000000..e81f893 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/defines_invalid/repo/packages/bad_watchos/pubspec.yaml @@ -0,0 +1,10 @@ +name: bad_watchos +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true + defines: + 1BAD_CAMERA: NSCameraUsageDescription + BAD_EXTRA: [] + BAD_OK: NSMotionUsageDescription diff --git a/tool/test/fixtures/check_native_sources/defines_invalid/repo/packages/bad_watchos/watchos/Classes/bad_watchos_ffi.m b/tool/test/fixtures/check_native_sources/defines_invalid/repo/packages/bad_watchos/watchos/Classes/bad_watchos_ffi.m new file mode 100644 index 0000000..80b7205 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/defines_invalid/repo/packages/bad_watchos/watchos/Classes/bad_watchos_ffi.m @@ -0,0 +1 @@ +int bad_watchos_value(void) { return 1; } diff --git a/tool/test/fixtures/check_native_sources/plan_defines/expect b/tool/test/fixtures/check_native_sources/plan_defines/expect new file mode 100644 index 0000000..6f052ea --- /dev/null +++ b/tool/test/fixtures/check_native_sources/plan_defines/expect @@ -0,0 +1,13 @@ +0 +defines d_watchos: D_CAMERA D_EXTRA +plan clang watchos arm64-apple-watchos26.0 packages/d_watchos/watchos/Classes/d_watchos_ffi.m [no -D] +plan clang watchos arm64-apple-watchos26.0 packages/d_watchos/watchos/Classes/d_watchos_ffi.m [marker, every macro] -DFLUTTER_WATCHOS_PLUGIN_DEFINES=1 -DD_CAMERA=1 -DD_EXTRA=1 +plan clang watchos arm64-apple-watchos26.0 packages/d_watchos/watchos/Classes/d_watchos_ffi.m [marker alone] -DFLUTTER_WATCHOS_PLUGIN_DEFINES=1 +plan clang watchos arm64-apple-watchos26.0 packages/d_watchos/watchos/Classes/d_watchos_ffi.m [marker, D_CAMERA alone] -DFLUTTER_WATCHOS_PLUGIN_DEFINES=1 -DD_CAMERA=1 +plan clang watchos arm64-apple-watchos26.0 packages/d_watchos/watchos/Classes/d_watchos_ffi.m [marker, D_EXTRA alone] -DFLUTTER_WATCHOS_PLUGIN_DEFINES=1 -DD_EXTRA=1 +plan swiftc watchsimulator arm64-apple-watchos26.0-simulator d_watchos_views [no -D]: packages/d_watchos/watchos/Views/d_watchos_views.swift +plan swiftc watchsimulator arm64-apple-watchos26.0-simulator d_watchos_views [marker, every macro] -DFLUTTER_WATCHOS_PLUGIN_DEFINES -DD_CAMERA -DD_EXTRA: packages/d_watchos/watchos/Views/d_watchos_views.swift +plan swiftc watchsimulator arm64-apple-watchos26.0-simulator d_watchos_views [marker alone] -DFLUTTER_WATCHOS_PLUGIN_DEFINES: packages/d_watchos/watchos/Views/d_watchos_views.swift +plan swiftc watchsimulator arm64-apple-watchos26.0-simulator d_watchos_views [marker, D_CAMERA alone] -DFLUTTER_WATCHOS_PLUGIN_DEFINES -DD_CAMERA: packages/d_watchos/watchos/Views/d_watchos_views.swift +plan swiftc watchsimulator arm64-apple-watchos26.0-simulator d_watchos_views [marker, D_EXTRA alone] -DFLUTTER_WATCHOS_PLUGIN_DEFINES -DD_EXTRA: packages/d_watchos/watchos/Views/d_watchos_views.swift +plan clang watchos arm64-apple-watchos26.0 packages/e_watchos/watchos/Classes/e_watchos_ffi.m diff --git a/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/pubspec.yaml b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/pubspec.yaml new file mode 100644 index 0000000..8a169b0 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/pubspec.yaml @@ -0,0 +1,11 @@ +name: d_watchos +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true + defines: + D_CAMERA: NSCameraUsageDescription + D_EXTRA: + - DExtra + - DExtraToo diff --git a/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/watchos/Classes/d_watchos_ffi.m b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/watchos/Classes/d_watchos_ffi.m new file mode 100644 index 0000000..cbe1562 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/watchos/Classes/d_watchos_ffi.m @@ -0,0 +1 @@ +int d_watchos_value(void) { return 1; } diff --git a/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/watchos/Views/d_watchos_views.swift b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/watchos/Views/d_watchos_views.swift new file mode 100644 index 0000000..e6c6d46 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/d_watchos/watchos/Views/d_watchos_views.swift @@ -0,0 +1 @@ +import SwiftUI diff --git a/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/e_watchos/pubspec.yaml b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/e_watchos/pubspec.yaml new file mode 100644 index 0000000..2aca40f --- /dev/null +++ b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/e_watchos/pubspec.yaml @@ -0,0 +1,6 @@ +name: e_watchos +flutter: + plugin: + platforms: + watchos: + ffiPlugin: true diff --git a/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/e_watchos/watchos/Classes/e_watchos_ffi.m b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/e_watchos/watchos/Classes/e_watchos_ffi.m new file mode 100644 index 0000000..e0c38c6 --- /dev/null +++ b/tool/test/fixtures/check_native_sources/plan_defines/repo/packages/e_watchos/watchos/Classes/e_watchos_ffi.m @@ -0,0 +1 @@ +int e_watchos_value(void) { return 1; } diff --git a/tool/test/plugin_defines_test.dart b/tool/test/plugin_defines_test.dart new file mode 100644 index 0000000..12f4f74 --- /dev/null +++ b/tool/test/plugin_defines_test.dart @@ -0,0 +1,100 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// lib/src/plugin_defines.dart: the `defines` a pubspec declares, read as the +// CLI reads them, which tool/check_native_sources.sh compiles each plugin +// with. tool/test/check_native_sources_test.dart runs it through the script. + +import 'package:plugins_tool/src/plugin_defines.dart'; +import 'package:test/test.dart'; + +/// A plugin pubspec whose `watchos:` block ends with [lines]. +String _pubspec(String lines) { + return 'name: gadget_watchos\n' + 'flutter:\n' + ' plugin:\n' + ' platforms:\n' + ' watchos:\n' + ' ffiPlugin: true\n' + '$lines'; +} + +void main() { + test('reads a key or a list of keys, in the order declared', () { + final PluginDefines defines = readPluginDefines( + _pubspec( + ' defines:\n' + ' GADGET_LOCATION:\n' + ' - NSLocationWhenInUseUsageDescription\n' + ' - NSLocationAlwaysAndWhenInUseUsageDescription\n' + ' GADGET_CAMERA: NSCameraUsageDescription\n', + ), + ); + expect(defines.macros, ['GADGET_LOCATION', 'GADGET_CAMERA']); + expect(defines.problems, isEmpty); + }); + + test('a pubspec without defines, or with an empty value, declares none', () { + for (final String pubspec in [ + _pubspec(''), + _pubspec(' defines:\n'), + _pubspec(' defines: {}\n'), + 'name: not_a_plugin\n', + ]) { + final PluginDefines defines = readPluginDefines(pubspec); + expect(defines.macros, isEmpty, reason: pubspec); + expect(defines.problems, isEmpty, reason: pubspec); + } + }); + + // The CLI's own words, so the check and a build refuse the same pubspec. + for (final (String label, String lines, String problem) + in <(String, String, String)>[ + ( + 'a list instead of a map', + ' defines:\n - GADGET_CAMERA\n', + '`defines` is a list, not a map.', + ), + ( + 'a macro that is not a C identifier', + ' defines:\n GADGET-CAMERA: NSCameraUsageDescription\n', + '`GADGET-CAMERA` is not a C identifier.', + ), + ( + 'a key that is a number', + ' defines:\n GADGET_CAMERA: 7\n', + '`GADGET_CAMERA` maps to 7, not an Info.plist key or a list of keys.', + ), + ( + 'no key', + ' defines:\n GADGET_CAMERA:\n', + '`GADGET_CAMERA` names no Info.plist key.', + ), + ( + 'an empty key', + " defines:\n GADGET_CAMERA: ''\n", + '`GADGET_CAMERA` names an empty Info.plist key.', + ), + ( + 'an empty list', + ' defines:\n GADGET_CAMERA: []\n', + '`GADGET_CAMERA` lists no Info.plist key.', + ), + ( + 'a list with a number', + ' defines:\n GADGET_CAMERA: [NSCameraUsageDescription, 7]\n', + '`GADGET_CAMERA` lists 7, which is not an Info.plist key.', + ), + ]) { + test('refuses $label', () { + final PluginDefines defines = readPluginDefines(_pubspec(lines)); + expect(defines.problems, [problem]); + }); + } + + test('a pubspec that does not parse is refused', () { + final PluginDefines defines = readPluginDefines('name: [unclosed\n'); + expect(defines.problems.single, startsWith('pubspec.yaml does not parse')); + }); +}