Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
- The production-shaped fixture pins five values with `checks:`.
- `--list-rules` also lists the project checks from the configuration file,
with their severity and reason.
- `purpose-string-missing` gives each built-in plugin key a level, with a
link to the plugin README section it comes from (table in
`doc/rules.md`). A missing required key is still an error; a missing
conditional key is a warning that states the condition. image_picker's
`NSMicrophoneUsageDescription` is conditional ("only if you record video"),
so an image_picker app without it now gets a warning instead of an error.
`purpose-string-quality` accepts a "not used" string for a conditional key.
- The `rejected_app` fixture links `local_auth` without
`NSFaceIDUsageDescription`, so it misses one required and one conditional
key.

## 0.2.0

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,22 @@ privacy-manifest-present — The app target ships a PrivacyInfo.xcprivacy
purpose-string-missing — Linked plugins have the purpose strings they need
error ios/Runner/Info.plist
`NSPhotoLibraryUsageDescription` is missing, but image_picker needs it.
error ios/Runner/Info.plist
`NSMicrophoneUsageDescription` is missing, but image_picker needs it.
warning ios/Runner/Info.plist
`NSMicrophoneUsageDescription` is missing; image_picker needs it only if you
record video (image_picker README, "iOS").

purpose-string-quality — Purpose strings name a feature and give an example, not restate the permission
error ios/Runner/Info.plist:5
`NSCameraUsageDescription` only restates the permission; name the feature
that uses it and give an example. Current text: "This app needs camera
access."

✗ 15 rules checked: 3 errors, 1 warning, 0 info. Failing: findings at or above `error`.
✗ 15 rules checked: 2 errors, 2 warnings, 0 info. Failing: findings at or above `error`.
```

The exit code is `1`, so CI fails. Without `--brief` each finding also says
A key a plugin needs in every app is an error; a key its README asks for
only when you use one feature, like the microphone for recording video, is a
warning that names the feature. The exit code is `1`, so CI fails. Without `--brief` each finding also says
why it matters, how to fix it, and links Apple's or Google's documentation.
`--format json` and `--sarif` give the same findings in machine-readable form.

Expand Down
2 changes: 1 addition & 1 deletion doc/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ code 2) with the line number, so a typo cannot silently switch a check off.
|---|---|
| `privacy-manifest-values` | `extra_data_types`, `extra_purposes`, `extra_api_reasons` (category → reasons) |
| `privacy-manifest-sdk-data` | `sdk_data_types` (package → data types; `[]` disables a built-in entry) |
| `purpose-string-missing` | `required_by_plugin` (package → Info.plist keys it requests), `linked_by_plugin` (package → Info.plist keys its binary only references) |
| `purpose-string-missing` | `required_by_plugin` (package → Info.plist keys it requests, all required; replaces a built-in row), `linked_by_plugin` (package → Info.plist keys its binary only references) |
| `purpose-string-quality` | `min_words`, `require_example`, `app_names`, `extra_example_markers`, `extra_unused_markers`, `linked_only` (Info.plist key → why it is there) |
| `background-modes-justified` | `allow` (mode → justification), `extra_evidence` (mode → packages) |
| `android-picker-queries` | `plugins` (extra file-picker packages) |
Expand Down
45 changes: 41 additions & 4 deletions doc/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Planned rules link the issue that tracks them.
| `privacy-manifest-tracking` | `NSPrivacyTracking` agrees with each entry's Tracking flag and with `NSPrivacyTrackingDomains` | A manifest that contradicts itself and the privacy label |
| `privacy-manifest-sdk-data` | Collected data covers what linked SDKs (Firebase, AdMob, StoreKit, Sentry, …) collect, read from `pubspec.lock` | "Collects nothing" next to an analytics SDK, a label/manifest mismatch reviewers flag |
| `tracking-usage-description` | `NSUserTrackingUsageDescription` exists exactly when `PrivacyInfo.xcprivacy` declares tracking; warns when `app_tracking_transparency` is linked without it | App Store Connect refusing "Add for Review"; an ATT request that terminates the app |
| `purpose-string-missing` | Plugins that touch the camera, microphone, photos, location, contacts, Face ID… have their `NS…UsageDescription`; Always location also has When-In-Use; protected APIs a bundled SDK only references (`linked_by_plugin`) have their key too, both location keys for a CoreLocation reference | ITMS-90683 "Missing purpose string", also for an API the app never calls; a crash at the first permission request |
| `purpose-string-missing` | Plugins that touch the camera, microphone, photos, location, contacts, Face ID… have their `NS…UsageDescription` (an error for a required key, a warning that states the condition for a conditional one; see [Plugin purpose strings](#plugin-purpose-strings)); Always location also has When-In-Use; protected APIs a bundled SDK only references (`linked_by_plugin`) have their key too, both location keys for a CoreLocation reference | ITMS-90683 "Missing purpose string", also for an API the app never calls; a crash at the first permission request |
| `purpose-string-quality` | Purpose strings name a feature and give an example instead of restating the permission; a string for a key the app does not use says so and why the key is there (recognised by its wording or listed under `linked_only`), and is an error while a plugin that requests the permission is a direct dependency | Guideline 5.1.1(ii) rejection ("needs camera access"); a "not used" string users see in a real permission prompt |
| `background-modes-justified` | Every `UIBackgroundModes` entry is a mode Apple defines and has a justification recorded under `allow`: an error when no linked plugin uses the mode, a warning when one plausibly does; `processing` also needs `BGTaskSchedulerPermittedIdentifiers` | Guideline 2.5.4 rejection ("unable to locate any features that require persistent audio") |
| `export-options-build-number` | Every `ExportOptions*.plist` with method `app-store` / `app-store-connect` sets `manageAppVersionAndBuildNumber` to false (it defaults to YES) | Xcode silently bumping `CFBundleVersion` at export |
Expand All @@ -47,6 +47,39 @@ Planned rules link the issue that tracks them.
| `android-16kb-page-size` | When `targetSdk` is 35+ or cannot be determined: Android Gradle Plugin ≥ 8.5.1 (or legacy packaging), NDK r28+ for your own native code | Play not letting you release an update without 16 KB page-size support, from 1 February 2027 ([Android guide](https://developer.android.com/guide/practices/page-sizes#google-play-compatibility-requirement)) |
| `unreadable-file` | Every file above parses (binary plists get a `plutil` hint) | Rules silently passing a file nobody could read |

## Plugin purpose strings

`purpose-string-missing` knows the Info.plist keys these plugins ask for. Each
key is **required** (every app that links the plugin needs it; missing is an
error) or **conditional** (the plugin's README asks for it only when you use
one feature; missing is a warning that names the feature). A key is
conditional only when the plugin's own README says so. Keys you add with
`required_by_plugin` are required.

| Plugin | Required | Conditional | Source |
|---|---|---|---|
| `image_picker` | `NSPhotoLibraryUsageDescription`, `NSCameraUsageDescription` | `NSMicrophoneUsageDescription`: only if you record video | [README, "iOS"](https://pub.dev/packages/image_picker#ios) |
| `camera` | `NSCameraUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/camera#ios) |
| `mobile_scanner` | `NSCameraUsageDescription` | | [README, "iOS"](https://pub.dev/packages/mobile_scanner#ios) |
| `geolocator` | `NSLocationWhenInUseUsageDescription` | | [README, "Usage" → "iOS"](https://pub.dev/packages/geolocator#usage) |
| `location` | `NSLocationWhenInUseUsageDescription` | | [README, "iOS"](https://pub.dev/packages/location#ios) |
| `record` | `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/record#ios) |
| `flutter_sound` | `NSMicrophoneUsageDescription` | | [Installation guide, "Post Installation"](https://flutter-sound.canardoux.xyz/tau/guides/guides-install.html#post-installation) |
| `speech_to_text` | `NSSpeechRecognitionUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS & macOS"](https://pub.dev/packages/speech_to_text#ios--macos) |
| `local_auth` | `NSFaceIDUsageDescription` | | [local_auth_darwin README, "Setup"](https://pub.dev/packages/local_auth_darwin#setup) |
| `flutter_contacts` | `NSContactsUsageDescription` | | [README, "Configure Permissions"](https://pub.dev/packages/flutter_contacts#2-configure-permissions) |
| `flutter_blue_plus` | `NSBluetoothAlwaysUsageDescription` | | [README, "Add permissions for iOS"](https://pub.dev/packages/flutter_blue_plus#add-permissions-for-ios) |
| `photo_manager` | `NSPhotoLibraryUsageDescription` | | [README, "iOS config preparation"](https://pub.dev/packages/photo_manager#ios-config-preparation) |
| `gal` | `NSPhotoLibraryAddUsageDescription` | | [README, "iOS Setup"](https://pub.dev/packages/gal#ios-setup) |
| `flutter_webrtc` | `NSCameraUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/flutter_webrtc#ios) |
| `livekit_client` | `NSCameraUsageDescription`, `NSMicrophoneUsageDescription` | | [README, "iOS"](https://pub.dev/packages/livekit_client#ios) |
| `agora_rtc_engine` | `NSMicrophoneUsageDescription`, `NSCameraUsageDescription` | | [README, "iOS & macOS"](https://pub.dev/packages/agora_rtc_engine#ios--macos) |

When another linked plugin requires the same key, or a bundled SDK references
its API (`linked_by_plugin`), the missing key is an error. A purpose string
that says the app does not use a conditional key's resource is not reported
by `purpose-string-quality`, since the app may not use that feature.

## What a finding looks like

Without `--brief`, every rule also says
Expand All @@ -65,14 +98,18 @@ purpose-string-missing — Linked plugins have the purpose strings they need
reaches the permission prompt. The upload check is static: a protected API
that a bundled native SDK only references (linked, never requested) needs its
key too, and a CoreLocation reference usually includes the Always
authorization API, so both location keys are asked for.
authorization API, so both location keys are asked for. A key a plugin's
README asks for only when you use one of its features (image_picker's
microphone key, for recording video) is a warning that names the feature.
fix: Add the key to ios/Runner/Info.plist with a sentence that names the
feature and gives an example (see `purpose-string-quality`). When a dependency
only references the API, add the key anyway with a sentence saying the app
does not use the resource and why the key is present (see
`purpose-string-quality` -> `linked_only`); deleting it only fails the next
upload. Extend the plugin table with the `required_by_plugin` option, and
record SDKs that only reference an API with `linked_by_plugin`.
upload. For a warning about a key needed only for one feature, add the key if
the app uses that feature, or `ignore` the finding with the reason. Extend the
plugin table with the `required_by_plugin` option, and record SDKs that only
reference an API with `linked_by_plugin`.
docs: https://developer.apple.com/documentation/uikit/requesting-access-to-protected-resources
```

Expand Down
189 changes: 153 additions & 36 deletions lib/src/data/plugin_requirements.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,43 +36,160 @@ const backgroundModeEvidence = <String, List<String>>{
'bluetooth-peripheral': ['flutter_ble_peripheral'],
};

/// Info.plist purpose-string keys each plugin's README says it needs.
/// Whether a plugin needs a purpose string in every app that links it.
enum KeyLevel {
/// Every app that links the plugin needs the key.
required,

/// Only apps that use one feature of the plugin need the key; the plugin's
/// README names the feature.
conditional,
}

/// One Info.plist purpose-string key a plugin's README asks for.
class PluginKey {
/// A key every app that links the plugin needs.
const PluginKey.required(this.key) : condition = null;

/// A key needed only when [condition] holds.
const PluginKey.conditional(this.key, String this.condition);

/// The Info.plist key.
final String key;

/// When the key is needed, completing "needed only if …" (for example
/// `you record video`); `null` for a required key.
final String? condition;

/// [KeyLevel.conditional] when the README gives a condition.
KeyLevel get level =>
condition == null ? KeyLevel.required : KeyLevel.conditional;
}

/// The purpose strings one plugin asks for, and where its README says so.
class PluginPurposeStrings {
/// Creates a table row.
const PluginPurposeStrings({
required this.source,
required this.section,
required this.keys,
});

/// Link to the README (or setup guide) section that lists the keys.
final String source;

/// Where [source] points, as a reader finds it: `image_picker README,
/// "iOS"`.
final String section;

/// The keys, each with its level.
final List<PluginKey> keys;
}

const _camera = PluginKey.required('NSCameraUsageDescription');
const _microphone = PluginKey.required('NSMicrophoneUsageDescription');
const _photoLibrary = PluginKey.required('NSPhotoLibraryUsageDescription');
const _whenInUse = PluginKey.required('NSLocationWhenInUseUsageDescription');

/// Info.plist purpose-string keys each plugin's README asks for, with the
/// level the README gives each one.
///
/// A missing key means the build is refused at upload with ITMS-90683, or the
/// app is terminated by iOS the first time the API is called.
const purposeStringsByPlugin = <String, List<String>>{
'image_picker': [
'NSPhotoLibraryUsageDescription',
'NSCameraUsageDescription',
'NSMicrophoneUsageDescription',
],
'camera': ['NSCameraUsageDescription', 'NSMicrophoneUsageDescription'],
'mobile_scanner': ['NSCameraUsageDescription'],
'geolocator': ['NSLocationWhenInUseUsageDescription'],
'location': ['NSLocationWhenInUseUsageDescription'],
'record': ['NSMicrophoneUsageDescription'],
'flutter_sound': ['NSMicrophoneUsageDescription'],
'speech_to_text': [
'NSSpeechRecognitionUsageDescription',
'NSMicrophoneUsageDescription',
],
'local_auth': ['NSFaceIDUsageDescription'],
'flutter_contacts': ['NSContactsUsageDescription'],
'flutter_blue_plus': ['NSBluetoothAlwaysUsageDescription'],
'photo_manager': ['NSPhotoLibraryUsageDescription'],
'gal': ['NSPhotoLibraryAddUsageDescription'],
'flutter_webrtc': [
'NSCameraUsageDescription',
'NSMicrophoneUsageDescription',
],
'livekit_client': [
'NSCameraUsageDescription',
'NSMicrophoneUsageDescription',
],
'agora_rtc_engine': [
'NSCameraUsageDescription',
'NSMicrophoneUsageDescription',
],
/// A missing required key means the build is refused at upload with
/// ITMS-90683, or the app is terminated by iOS the first time the API is
/// called. A key is conditional only when the plugin's own README says it is
/// needed for one feature; the condition is quoted from there.
const purposeStringsByPlugin = <String, PluginPurposeStrings>{
'image_picker': PluginPurposeStrings(
source: 'https://pub.dev/packages/image_picker#ios',
section: 'image_picker README, "iOS"',
keys: [
_photoLibrary,
_camera,
// "describe why your app needs access to the microphone, if you
// intend to record videos."
PluginKey.conditional('NSMicrophoneUsageDescription', 'you record video'),
],
),
'camera': PluginPurposeStrings(
source: 'https://pub.dev/packages/camera#ios',
section: 'camera README, "iOS"',
keys: [_camera, _microphone],
),
'mobile_scanner': PluginPurposeStrings(
source: 'https://pub.dev/packages/mobile_scanner#ios',
section: 'mobile_scanner README, "iOS"',
keys: [_camera],
),
'geolocator': PluginPurposeStrings(
source: 'https://pub.dev/packages/geolocator#usage',
section: 'geolocator README, "Usage" -> "iOS"',
keys: [_whenInUse],
),
'location': PluginPurposeStrings(
source: 'https://pub.dev/packages/location#ios',
section: 'location README, "iOS"',
keys: [_whenInUse],
),
'record': PluginPurposeStrings(
source: 'https://pub.dev/packages/record#ios',
section: 'record README, "iOS"',
keys: [_microphone],
),
'flutter_sound': PluginPurposeStrings(
source:
'https://flutter-sound.canardoux.xyz/tau/guides/guides-install.html#post-installation',
section: 'Flutter Sound installation guide, "Post Installation"',
keys: [_microphone],
),
'speech_to_text': PluginPurposeStrings(
source: 'https://pub.dev/packages/speech_to_text#ios--macos',
section: 'speech_to_text README, "iOS & macOS"',
keys: [
PluginKey.required('NSSpeechRecognitionUsageDescription'),
_microphone,
],
),
'local_auth': PluginPurposeStrings(
source: 'https://pub.dev/packages/local_auth_darwin#setup',
section: 'local_auth_darwin README, "Setup"',
keys: [PluginKey.required('NSFaceIDUsageDescription')],
),
'flutter_contacts': PluginPurposeStrings(
source: 'https://pub.dev/packages/flutter_contacts#2-configure-permissions',
section: 'flutter_contacts README, "Configure Permissions"',
keys: [PluginKey.required('NSContactsUsageDescription')],
),
'flutter_blue_plus': PluginPurposeStrings(
source:
'https://pub.dev/packages/flutter_blue_plus#add-permissions-for-ios',
section: 'flutter_blue_plus README, "Add permissions for iOS"',
keys: [PluginKey.required('NSBluetoothAlwaysUsageDescription')],
),
'photo_manager': PluginPurposeStrings(
source: 'https://pub.dev/packages/photo_manager#ios-config-preparation',
section: 'photo_manager README, "iOS config preparation"',
keys: [_photoLibrary],
),
'gal': PluginPurposeStrings(
source: 'https://pub.dev/packages/gal#ios-setup',
section: 'gal README, "iOS Setup"',
keys: [PluginKey.required('NSPhotoLibraryAddUsageDescription')],
),
'flutter_webrtc': PluginPurposeStrings(
source: 'https://pub.dev/packages/flutter_webrtc#ios',
section: 'flutter_webrtc README, "iOS"',
keys: [_camera, _microphone],
),
'livekit_client': PluginPurposeStrings(
source: 'https://pub.dev/packages/livekit_client#ios',
section: 'livekit_client README, "iOS"',
keys: [_camera, _microphone],
),
'agora_rtc_engine': PluginPurposeStrings(
source: 'https://pub.dev/packages/agora_rtc_engine#ios--macos',
section: 'agora_rtc_engine README, "iOS & macOS"',
keys: [_microphone, _camera],
),
};

/// Words that describe the *permission itself* for each purpose-string key.
Expand Down
Loading