Skip to content

Declare the required reason APIs, which App Store Connect rejects without - #496

Merged
mishamyte merged 2 commits into
mainfrom
feat/privacy-manifest
Nov 12, 2026
Merged

mishamyte merged 2 commits into
mainfrom
feat/privacy-manifest

Conversation

@mishamyte

@mishamyte mishamyte commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

The app had no privacy manifest at all. Since 1 May 2024 App Store Connect
does not accept an upload whose manifest fails to describe the required reason
APIs the app uses — a hard gate, before a human sees the build. Nothing here
could have found it: no job on a pull request builds iOS, the release job
builds unsigned, and the first machine that checks is Apple's.

This closes b3 on the submission checklist, and it is the only one of the
four blockers that needs neither an account nor a Mac.

Apple's rule is per binary, and that shapes everything

For each executable or dynamic library in an app that uses a required reason
API, the bundle that includes the executable or dynamic library needs to
include a privacy manifest file that reports the API.

and, on the same page:

Your third-party SDK can't rely on the privacy manifest files for apps that
link the third-party SDK […] to report your third-party SDK's use of
required reasons API.

Both Podfiles say use_frameworks!, so every development pod is its own
dynamic framework. So the app's manifest answers for the app's binary, each pod
answers for its own — and two of the app's three entries are conservative
cover for uses that are real but live in someone else's binary. That is said
plainly rather than implied, because the first draft of this said the opposite
and a review caught it.

category reason why
UserDefaults CA92.1 flutter_foreground_task and universal_ble reach UserDefaults.standard and ship no declaration. CA92.1 and not 1C8F.1, the App Group variant: this app has no entitlements file at all.
SystemBootTime 35F9.1 Conservative cover for the engine, which declares 35F9.1 itself, and Sentry's Cocoa pod. The call this repository owns is declared in the pod's own manifest.
FileTimestamp C617.1 Every first-party File.stat() goes through dart:io, so the symbol is in Flutter.framework, which declares this itself. Not DDA9.1, which forbids sending anything derived off-device — a promise a log line cannot keep. Not 0A2A.1, which Apple allows only to third-party SDKs; that is how the engine declares it and why this app may not.

qunleashed_hardnested gets a manifest of its own, through resource_bundles.
util_posix.c carries a pre-10.12 clock_gettime fallback calling
mach_absolute_time(). That branch never executes on our deployment targets —
_civet_safe_clock_gettime prefers the real clock_gettime whenever it is
non-NULL, which on iOS 12 and macOS 10.15 is always — but Apple's check is a
static scan and the symbol is in the binary either way. 35F9.1 is honest for
it: the shim subtracts its own first reading, so an elapsed time leaves the
function and never an absolute mach value.

The data half

From ADR 0013 rather than from guesswork: crash, performance and other
diagnostic data for Sentry, which is on by default with one switch, and a
device id for Firebase's installation id. All four unlinked, none for tracking
— there are no accounts and sendDefaultPii is false.

Precise location is deliberately absent. The app reads location and never
sends it: §6.2's scrubber replaces decimal coordinates before an event leaves,
and §6.4 masks FlutterMap in the replay for exactly that reason. Declaring it
would be a false statement in the stricter direction.

The gate, and the half of it with a future

test/privacy_manifest_test.dart stands in for App Store Connect. It asserts
both manifests as a token sequence — every key, string, boolean and array
in document order, comments stripped — because values alone were not enough: a
reviewer showed that renaming NSPrivacyAccessedAPITypes hides the entire API
half of the file while a value-only assertion stays green. The same review
showed the build-phase assertion had to be scoped to the Runner target, because
moving the entry into the empty RunnerTests phase also passed, and would ship
the manifest inside RunnerTests.xctest with none in the .app.

Then Apple's per-binary rule, checked twice:

  • Over every Apple source this checkout has, submodules included, via git ls-files --recurse-submodules. Walking those directories from disk instead
    picked up gitignored build output, so what the scan covered depended on what
    the developer had last compiled. Four hits are pinned as outside the iOS app,
    individually and with the reason: libusb's Darwin and Linux backends, and
    super_native_extensions' darwin/macos and win32 Rust.
  • Over every iOS plugin Flutter resolved, read from
    .flutter-plugins-dependencies. This is the half that moves: our own Apple
    code uses one such API and now ships a manifest beside it, while the plugin
    set changes on every dependency bump — and a bump is how CA92.1 came to be
    needed at all. The pinned set today is three, so a fourth fails with the
    plugin's name instead of arriving as a rejected upload.
finding answered by
flutter_foreground_task:UserDefaults the app's CA92.1
universal_ble:UserDefaults the app's CA92.1
device_info_plus:DiskSpace nothing, deliberately — below

Two rules keep that honest, both derived rather than listed: a plugin whose iOS
podspec declares no source_files compiles nothing into the app and is skipped
(flipperlib's position, and it stops being true the day it gains native iOS
code), and Package.swift is read as the build manifest it is — which was
permission_handler_apple's only apparent FileTimestamp use.

Fourteen mutations were run against the finished test, including emptying a
reasons array, dropping the podspec's resource_bundles, and disabling each of
the two derived rules. Each failed the test whose name describes it.

One category is used and declared nowhere, and it is not ours to declare

device_info_plus 11.5.0 reads NSFileSystemSize unconditionally in
FPPDeviceInfoPlusPlugin.m and ships an empty NSPrivacyAccessedAPITypes.
That call is reached on iOS: our own fork of super_native_extensions
awaits DeviceInfoPlugin().deviceInfo in long_press_handler.dart with no
platform guard on every long-press drag, then reads the result only under if (deviceInfo is AndroidDeviceInfo) — so on iOS the value is fetched and thrown
away. The sibling call in super_context_menu is guarded with
defaultTargetPlatform == TargetPlatform.android and shows the fix.

No disk-space reason is declared here, because none of Apple's four describes
fetching a number and discarding it, and the symbol is in that plugin's
framework rather than ours. docs/releasing.md names the three ways out in the
order they are worth trying: the guard in our fork, then device_info_plus 13
from the win32 chain, and only failing both a declaration — which is a
statement whoever submits has to be willing to make.

This correction came from review. The first draft argued the gap away with
"this app calls device_info_plus only for androidInfo", which is false.

Scope

  • macOS gets no manifest. Apple's page says "on iOS, iPadOS, tvOS,
    visionOS, or watchOS"; macOS is absent, and ships here as a DMG outside the
    Mac App Store.
  • flipperlib's libusb calls mach_absolute_time() in os/darwin_usb.c and
    is a submodule — but its iOS podspec compiles no native code at all, so it
    reaches the macOS pod only. Pinned with that reason rather than left silent.
  • A CHANGELOG entry, in Other changes. Left out at first on the grounds
    that nothing a user can observe changes. That was wrong: the manifest is what
    the App Store listing is generated from, so the observable part is the
    listing — what the app sends, and that the location it reads for the map is
    not among it. Build the App Bundle a Play submission needs, and verify both artifact kinds #491 is the precedent, equally invisible in the app and
    carrying an Android: bullet for the App Bundle.

Verified

  • flutter analyze clean, check_format.sh clean, 2546 tests pass. One
    full run had a transient load failure in faaccrack_obfuscation_test.dart
    immediately after the mutation battery; it passes in isolation and the suite
    passes on re-run.
  • Every reason code and constant read from Apple's own documentation payloads,
    not from memory.
  • project.pbxproj was hand-edited from Windows, and a reviewer parsed it with
    an OpenStep plist parser: 91 objects, no duplicate keys, ids unique, every
    object in its own section, and the entry in the Runner target's phase and
    not RunnerTests'.
  • resource_bundles matches how share_plus, file_picker and
    device_info_plus ship theirs, in both their iOS and macOS podspecs.

What this does not prove

That the upload is accepted. Nothing here can build or sign an iOS app, so the
first real evidence is b2 — and if Apple's scanner attributes
device_info_plus' disk-space symbol to the app, that upload will say so by
name.

Not in this PR, and worth knowing

iOS has no push entitlement. There is no .entitlements file anywhere
under ios/, no CODE_SIGN_ENTITLEMENTS in the project, and no
remote-notification in UIBackgroundModes — so no APNs token can be issued.
push_service.dart's dartdoc reasons that only macOS lacks aps-environment
and treats iOS as push-capable for topicsSupported. That belongs to b2.

…hout

The app had no privacy manifest at all. Since 1 May 2024 App Store Connect
does not accept an upload whose manifest fails to describe the required reason
APIs the app uses - a hard gate, before a human sees the build - so this was a
rejection waiting for the first upload, and nothing here could have found it:
no job on a pull request builds iOS, the release job builds unsigned, and the
first machine that checks is Apple's.

Apple's rule is **per binary**: each executable or dynamic library that uses
such an API needs a manifest in its own bundle, and the same page says a
third-party SDK "can't rely on the privacy manifest files for apps that link
the third-party SDK". Both Podfiles say `use_frameworks!`, so every
development pod here is its own dynamic framework. That shapes the whole
change, and it means two of the app's three entries are **conservative** cover
for uses that are real but live in someone else's binary:

* `UserDefaults` -> `CA92.1`. `flutter_foreground_task` 9.2.2 and
  `universal_ble` 3.3.0 both reach `UserDefaults.standard` and ship no
  declaration. `CA92.1` and not `1C8F.1`, which is the App Group variant:
  this app has no entitlements file at all.
* `SystemBootTime` -> `35F9.1`. The engine declares `35F9.1` itself and
  `sentry_flutter` wraps a Cocoa SDK with its own manifest. The call this
  repository owns is declared in **the pod's** manifest instead, below.
* `FileTimestamp` -> `C617.1`. Every first-party `File.stat()` goes through
  `dart:io`, so the symbol is in `Flutter.framework`, which declares this
  category itself. Not `DDA9.1`, which forbids sending anything derived
  off-device - a promise a log line cannot keep. Not `0A2A.1`, which Apple
  allows only to third-party SDKs; that is how the engine declares it and why
  this app may not.

`qunleashed_hardnested` gets a manifest of its own, shipped through
`resource_bundles` - the shape share_plus, file_picker and device_info_plus
all use. `util_posix.c`, which the Apple unity file #includes, carries a
pre-10.12 `clock_gettime` fallback that calls `mach_absolute_time()`. That
branch never executes on our deployment targets, because
`_civet_safe_clock_gettime` prefers the real `clock_gettime` whenever it is
non-NULL, which on iOS 12 and macOS 10.15 is always - but Apple's check is a
static scan and the symbol is in the binary either way. `35F9.1` is honest for
it: the shim subtracts its own first reading, so an elapsed time leaves the
function and never an absolute mach value.

The data half is declared from ADR 0013 rather than from guesswork: crash,
performance and other diagnostic data for Sentry, which is on by default with
one switch, and a device id for Firebase's installation id. All four unlinked
and none for tracking - there are no accounts and `sendDefaultPii` is false.
**Precise location is deliberately absent:** the app reads location and never
sends it, because §6.2's scrubber replaces decimal coordinates before an event
leaves and §6.4 masks `FlutterMap` in the replay for that exact reason.

Four edits in `project.pbxproj`. The `Resources` build phase entry is what
puts the file inside the `.app`; the build file and the file reference it
chains through are load-bearing for that copy; only the group membership is
cosmetic.

`test/privacy_manifest_test.dart` is the gate that exists on this machine, and
it asserts both manifests as a **token** sequence - every key, string, boolean
and array in document order, comments stripped. Values alone were not enough:
a reviewer showed that renaming `NSPrivacyAccessedAPITypes` hides the entire
API half of the file while a value-only assertion stays green. The same review
showed the phase assertion had to be scoped to the Runner target, because
moving the entry into the empty `RunnerTests` phase also passed - and would
ship the manifest inside `RunnerTests.xctest` with none in the `.app`.

It then checks Apple's per-binary rule twice over. Once against every Apple
source this checkout has, submodules included - `git ls-files
--recurse-submodules`, because walking those directories from disk picked up
whatever the last build left behind, so what the scan covered depended on the
machine. Four hits there are pinned as outside the iOS app, individually and
with the reason, so a new one fails rather than joining them: libusb's Darwin
and Linux backends, and super_native_extensions' `darwin/macos` and `win32`
Rust.

And once against **every iOS plugin Flutter resolved**, which is the half that
moves. The app's own Apple code uses one required reason API and now ships a
manifest beside it; the plugin set changes on every dependency bump, and a bump
is how `CA92.1` came to be needed at all. Reading
`.flutter-plugins-dependencies` pins the plugins that use such an API and
declare none themselves - today `flutter_foreground_task:UserDefaults`,
`universal_ble:UserDefaults` and `device_info_plus:DiskSpace` - so a fourth
fails the test with the plugin's name instead of arriving as a rejected upload.
Two rules keep that honest and both are derived rather than listed: a plugin
whose iOS podspec declares no `source_files` compiles nothing into the app and
is skipped, which is flipperlib's position and stops being true the day it
gains native iOS code; and `Package.swift` is read as the build manifest it is,
which was `permission_handler_apple`'s only apparent `FileTimestamp` use.

Fourteen mutations were run against the finished test - including emptying a
reasons array, dropping the podspec's `resource_bundles`, and disabling each of
the two derived rules above. Each failed the test whose name describes it.

Scoped deliberately: **macOS gets no manifest.** Apple's page says "Describe
the reasons your app or third-party SDK on iOS, iPadOS, tvOS, visionOS, or
watchOS uses these APIs", macOS is absent from that list, and macOS here ships
as a DMG outside the Mac App Store.

**One category is used and declared nowhere, and it is not ours to declare.**
`device_info_plus` 11.5.0 reads `NSFileSystemSize` unconditionally and ships an
empty `NSPrivacyAccessedAPITypes`. That call is reached on iOS:
`super_native_extensions`' `long_press_handler.dart` awaits
`DeviceInfoPlugin().deviceInfo` with no platform guard on every long-press
drag, then reads the result only under `if (deviceInfo is AndroidDeviceInfo)`.
No disk-space reason is declared here, because none of Apple's four describes
fetching a number and discarding it, and the symbol is in that plugin's
framework rather than ours. `docs/releasing.md` names the three ways out in
the order they are worth trying; the first is a guard in our own fork, which
the sibling call in `super_context_menu` already has.
@mishamyte
mishamyte requested a review from apfxtech as a code owner November 12, 2026 09:57
@mishamyte mishamyte added this to the 0.17.3 milestone Nov 12, 2026
@mishamyte mishamyte self-assigned this Nov 12, 2026
@github-actions github-actions Bot added area/emulator Virtual Flipper engine area/platform Android, iOS, Linux, macOS, Windows, web area/docs README, changelog and screenshots labels Nov 12, 2026
The privacy manifest is what the App Store listing is generated from, so the
part a user can read is the listing: what the app sends, and that the location
it reads for the map is not among it. #491 set the precedent - it was equally
invisible in the app and carries an `Android:` bullet for the App Bundle.

releasing.md leaves presence to judgement rather than to `check_changelog.sh`,
which checks shape only. This was first left out on the grounds that nothing a
user can observe changes; the listing is observable, and the judgement was
wrong.
@mishamyte
mishamyte merged commit 1fc4998 into main Nov 12, 2026
3 checks passed
@mishamyte
mishamyte deleted the feat/privacy-manifest branch November 12, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs README, changelog and screenshots area/emulator Virtual Flipper engine area/platform Android, iOS, Linux, macOS, Windows, web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant