We read all 41 privacy manifests inside our iOS app.
Unzip an iOS app and search it for files named PrivacyInfo.xcprivacy. Ours has
41: one for the app and one inside most of the frameworks it links. Most developers meet
these files once, through an App Store Connect email citing ITMS-91053, add whatever makes
the email go away, and never open them again. I opened all 41, checked them against the
binaries they describe, then checked ours against our own code. The dependencies held up.
One line we wrote ourselves didn’t.
Four keys, mostly empty
A privacy manifest is a small property list with four top-level keys.
NSPrivacyTracking says whether the code tracks people in Apple’s sense of
the word, and NSPrivacyTrackingDomains lists the hosts it contacts to do it.
NSPrivacyCollectedDataTypes lists the data it collects and what for.
NSPrivacyAccessedAPITypes lists the “required reason” APIs the code
calls, each with a code from Apple’s list of approved reasons. Apple introduced the
format at WWDC23, and since May 1, 2024 App Store Connect has refused uploads that use those
APIs without a declared reason.
Here’s what our 41 say. Nineteen declare nothing at all: every array empty, tracking set to false. Eighteen declare at least one required-reason API. Eight declare collected data. None claims to track, and none lists a tracking domain.
The empty ones aren’t laziness. Apple lists 86 commonly used SDKs (Firebase, Flutter,
Alamofire and a long run of Flutter plugins) that must ship a manifest when a new app or an
update adds them, plus a signature when they arrive as prebuilt binaries. An SDK on that
list with nothing to declare still ships the file, empty.
Thirty of the 51 frameworks in our bundle are on the list, and all thirty carry a manifest.
The 13 frameworks with none at all, including FirebaseStorage, FirebaseFunctions and the
App.framework that holds our compiled Dart code, are all off it.
stat() needs a reason now
Required-reason APIs come in five categories, and Apple’s stated concern is
fingerprinting: values that can be stitched together to identify a device without asking
anyone. The categories are broader than their names suggest. File timestamps covers
creationDate and modificationDate, and also stat,
fstat and lstat, the plain C calls that almost any code touching a
file makes. System boot time covers systemUptime and
mach_absolute_time. Disk space covers the free and total capacity keys and
statfs. Active keyboards is one property, activeInputModes. User
defaults is UserDefaults, all of it.
| Category | Manifests | Usual code |
|---|---|---|
| File timestamps | 11 | C617.1 |
| User defaults | 9 | CA92.1 |
| System boot time | 2 | 35F9.1 |
| Disk space | 1 | E174.1 |
| Active keyboards | 0 | — |
Each code stands for a sentence with conditions attached. C617.1 covers the metadata of files inside your own app container; 3B52.1 covers files the user picked. CA92.1 says the app reads and writes defaults only it can see, and 1C8F.1 widens that to an App Group. 35F9.1 is for measuring time between events in the app. Several codes also forbid sending anything derived from the call off the device.
That’s why gRPC, LevelDB and SDWebImage, libraries with no interest in who you are, all
declare C617.1. Each looks at its own files: LevelDB and gRPC call stat, and
SDWebImage reads modification dates to expire its image cache.
Checking the claims against the binaries
A manifest is a self-report, so I checked the reports. nm -u lists the symbols
a binary imports, and the C functions and Foundation constants in Apple’s categories
show up there by name. It’s a crude test that misses Objective-C methods called by
selector and can’t tell you why anything is called, but anyone holding your IPA can
run it in a minute.
Twelve binaries in our bundle import a required-reason API by name, and all twelve sit in a bundle whose manifest declares that category. None of the 13 frameworks without a manifest imports one. That’s the rule working as written: Apple wants the manifest in the same bundle as the executable that makes the call, and says an SDK “can’t rely on the privacy manifest files for apps that link” it. Whoever ships the code declares it, and Google, the Flutter team and the open-source maintainers had all done their part.
The codes that hand the reason back
Two codes work differently. 0A2A.1 for file timestamps and C56D.1 for user defaults mean:
this SDK wraps the API and only calls it when the app calls the wrapper, and it may not use
the result for its own purposes. GoogleUtilities declares one. So does Flutter: when Dart
code asks for a file’s size or modification date, the stat call happens
inside Flutter.framework, on the app’s behalf.
That catches Flutter developers out. The reason for those calls is yours, so it belongs in
your app’s manifest, even though none of your own native code calls stat.
Our Dart code reads the byte length of thumbnail files before uploading them, so the C617.1
entry in our manifest is earned.
The line we couldn’t justify
Our manifest declares four categories: user defaults, file timestamps, system boot time and
disk space. We added it in June during a pre-launch pass, and the commit message states its
purpose plainly: so the App Store accepts the upload. Neither of our own executables, the
main Runner binary (which also carries the FlutterFire plugins, linked
statically) and App.framework, imports a required-reason API by name.
So I traced each entry. File timestamps is earned through Flutter’s wrapper. User
defaults and boot time describe things the app really does, but the
shared_preferences plugin and the Flutter engine already declare their own calls,
so ours repeat them. Disk space is the problem. E174.1 covers checking whether there’s
room to write files, or deleting files when space runs low, and Apple attaches a condition:
“The app must behave differently based on disk space in a way that is observable to
users.” Ours doesn’t. Nothing in the bundle imports a disk-space API, Dart’s
standard library has no call for free space, and ShotCanvas has no low-storage behaviour of
any kind.
App Store Connect took the build anyway. In our case, at least, the check ran one way: an API you use needs a reason, but a reason you give doesn’t need a use. So the line sits in a file anyone can read by unzipping the app, claiming a feature we don’t have. It’s wrong, and deleting it is the whole fix.
The same audit on your own app
Rename your IPA to .zip and unzip it, or open the .app inside an
archive’s Products folder. Then, from inside the .app:
find . -name PrivacyInfo.xcprivacy | wc -l
for f in Frameworks/*.framework; do
n=$(basename "$f" .framework)
hits=$(nm -u "$f/$n" | grep -oE '_(f?stat|lstat|f?statv?fs|mach_absolute_time)$|NSUserDefaults$' | sort -u | tr '\n' ' ')
if [ -n "$hits" ]; then echo "$n: $hits"; fi
done
It’s a first pass, not proof: it misses Foundation constants such as the
modification-date keys, which is how SDWebImage slips past it. Run nm -u on the
main executable too, and read any manifest with plutil -p. Every framework the
loop names should declare what it found.
For the collected-data half, Xcode does the adding up. Choose Product > Archive, Control-click the archive in the Organizer and choose Generate Privacy Report, and you get a PDF laid out like the App Store’s privacy labels with every manifest merged in. Eight of the eleven data types across our 41 files come from one of them, Google Sign-In’s, which lists everything from name and email address to coarse location, all linked to the user’s identity. That’s the SDK’s account of itself. The label in App Store Connect is still yours to fill in; the report is an input, not a substitute.
The part everyone sees
A privacy manifest is the part of a release nobody reads until something goes wrong. The screenshots are what everybody sees first. ShotCanvas covers that half: drop in your screens, pick a template, and export pixel-exact sets for every App Store and Google Play slot, or publish them straight to both stores.