Data Not Collected, all afternoon
An iPhone app that runs GPS in your pocket for three hours, wakes itself when you arrive somewhere, and has an App Store privacy label that says it collects nothing. Here is what that takes, and the two places it broke.
The app is a walking game. You pick a city, its old town becomes a level, and every street is lined with dots you collect by physically walking it. What matters here is the problem it forces: continuous background location, for hours, in a pocket, started without anyone pressing a button. That is the exact profile of the apps people are right to distrust. I wanted the label to say Data Not Collected and be literally true.
What actually leaves the phone
Three things, all anonymous or yours. The app downloads a catalog and city packs from a static host (plain GETs of JSON files, no query parameters, no identifiers). StoreKit talks to Apple when you buy a city. And your progress (which streets you finished, not where you were) backs up to your own iCloud key-value store, so a reinstall does not erase an afternoon. That backup is on by default and has a switch. Apple's definition of "collected" turns on whether the developer can access the data. I cannot: it sits in your iCloud, not mine.
One more request goes out, and it is not mine: the map is Apple's MapKit, so the phone asks Apple for the tiles of the area on screen, like any map app does. Those requests tell Apple roughly where you are looking. I see none of it.
The location trail itself is never uploaded, anywhere, ever. There is no account, no analytics
SDK, no crash reporter. NSPrivacyCollectedDataTypes in the privacy manifest is an
empty array, with a comment above it explaining that adding an entry means something now
leaves the device.
Two modes: dormant and playing
Dormant is the normal state: no GPS at all. The app registers a geofence
(a CLCircularRegion) around each city you own, plus significant-location-change
monitoring as a backstop. The OS runs region monitoring on low-power hardware and can relaunch
a terminated app for it. iOS caps you at 20 regions per app and drops the excess without a
callback, so the app arms the nearest 18. The geofences are a latency optimization, not the
guarantee: the significant-change wake re-scans every owned city, so a city outside the
nearest 18 is still caught.
That wake is coarser than its reputation. The commonly quoted figure is about 500 m. In a dense old town it woke my phone at 160 m and 247 m: it fires on cell handover, not distance. I had written the 500 m into a design doc as if it were data, and specced a fix on top of it that would have saved none of the dots it was meant to save (part one, below).
Playing starts when a region entry coincides with walking. Accuracy is
kCLLocationAccuracyNearestTenMeters with a 10 m distance filter and
activityType = .fitness. Best-available accuracy would be pointless: a dot
collects when a fix lands within 25 m, so centimetre GPS buys nothing but battery drain.
Grant only While Using and regions deliver nothing to a backgrounded app, so the app uses
UNLocationNotificationTrigger instead: the system posts an arrival notification,
and one tap starts the walk.
Offline packs, so the matching can stay local
Every city is precompiled on my Mac, from OpenStreetMap, by a command-line tool that shares one Swift package with the app: graph building, dot placement, geometry. The output is a JSON pack (Passau is 220 KB, Paris 1.4 MB) holding streets, dots every 20 m, and landmarks. Once bought, a pack plays fully offline. Matching a fix to dots is a spatial grid lookup on the phone. There is no server that could know where you are, because there is no server doing anything but serving files.
Debugging without telemetry
No analytics means no dashboard showing which sessions die in the background. What I have instead is a flight recorder. It exists only in a field-test build that I and a few testers install; the App Store build does not contain it, so no player can switch it on. Turned on, it makes the app append every state-machine input, fix, motion reading, command and battery reading to a local JSONL file. It never uploads. I walk a city, speak ground truth into a voice memo ("sitting down at the café"), then AirDrop the log to my Mac.
The session state machine is pure Swift with no CoreLocation in it, so the CLI replays a real walk through the real code and reports what it collected, what it refused, and why. Slices of those logs are test fixtures, so a tracking change gets argued against "these six dots on Lederergasse", not in the abstract. The cost: my sample is my own walks and a few friends' phones. It catches gross errors, not the long tail.
What broke, part one: a paused app is a suspended app
The original design had auto-pause: sit down in a café for three minutes, GPS turns off, and "motion resumes it" when you get up. The comment in the code said exactly that. The first Passau field test showed it was false in the pocket.
Stopping location updates removes the app's last reason to run in the background. iOS suspends it within about a minute. A suspended app gets no motion callbacks and no timers. During one café pause the log has zero records for 32 minutes: no 20-second ticks, nothing. I got up, walked out, and the phone slept on. It woke on a significant-change event 160 m down the street. The six dots I walked on the way were gone, and they could never come back, because nothing was running when I walked past them.
The obvious fix was a "departure fence": when pausing, drop a small geofence around the café so leaving it wakes the app. Before building it I measured it against the logs. A region exit cannot fire until you are outside the radius, so a 150 m fence has a 150 m floor. The lost dots sat 41 to 139 m from the café. At the other incident (a shopping mall) they sat 100 to 137 m away. The fence would have saved zero of nine lost dots, in both cases.
So nothing wake-based could close the hole. The fix was to stop turning GPS off.
CLLocationUpdate.liveUpdates (iOS 17) lets the system own the pause: it quiets
the radio when you are stationary and un-suspends the app when you move. In a desk test the
motion chip's transition and the first delivered fix were 38 ms apart, and it delivered 475
updates over 8.3 minutes in the background without the app ever being opened. Pausing became
a game state (no collection while you sit) instead of a radio state. On the next walk, the phone with motion access came through its café pauses without a hole.
What broke, part two: the app quietly depended on Motion & Fitness
Five days after that shipped to testers, a friend and I walked Passau again. Their phone recorded 6,808 fixes over 135 minutes. Mine recorded 124. One of my sessions never left the "arriving" state at all: zero fixes. The difference was a setting. I had turned Motion & Fitness off, the way a privacy-minded user would.
The system-managed pause is triggered by motion. Without motion access, the pocketed stream collapsed to sparse, minutes-apart deliveries, with one gap of 19 minutes. There was a fallback (inferring walking from GPS itself), but inferring walking from GPS needs a dense fix stream, which was exactly what had collapsed. Circular starvation. The replay showed zero recoverable losses: every dot a fix reached got collected. The fixes just never arrived.
The fix is a selection in the adapter: if motion access is exactly .authorized,
use liveUpdates; otherwise use the classic delegate stream with
pausesLocationUpdatesAutomatically = false, which opts out of the pause machinery
entirely and keeps the radio on until you leave town. That costs more battery, and the
Settings copy says so instead of claiming "works fine". The first draft keyed the choice on
"motion is usable", which is also true for not yet asked. That would have put every
upgrading user (who never sees onboarding, so is never asked) on the stream that collapses. A
review before coding caught it.
The lesson became a rule. Every OS capability a user can withhold (Motion, location tier, Precise Location, notifications, iCloud, Low Power Mode and the rest) gets a row in a capability matrix naming three things: the code that runs when it is off, a test asserting that path is chosen, and where the field log records which path a session took. A fallback that silently depends on the thing that failed is not a fallback.
The battery budget, and the wrong unit
The original target was 5% per hour of play. It was the wrong unit. The same 5%/h asks 391 mW of the iPhone SE I test on, around 800 mW of a large current iPhone, and 313 mW of that same SE once its battery ages to 80% health. A gate that turns red because the tester's phone got older is not a gate on my code.
So there are two numbers now. For players: a two-hour walk costs under 20% of a healthy SE-class battery (set on the smallest phone, because that is who gets stranded). For me: the app adds at most 250 mW over a control walk with the app not playing. A 112-minute walk on an SE 3 used 8% per hour, inside the player budget. A 138-minute walk on a larger phone came in at 937 mW.
Which is the uncomfortable part: every number above is whole-phone. The app's own cost has never been isolated, because the control walk (same route, same phone, app idle) has not been walked. The replay tool prints "no gate verdict: needs a dormant control leg" on every run until it is.
What it costs
Building this way is slower. Every tracking bug is found by walking, not by querying. iOS still shows its periodic "used your location in the background" reminder, and the only defence is that there is nothing behind the curtain. In return I get a privacy label I never have to explain away.
If you want to see what it is for, the game is Cobble Gobble. If you are building something that follows people around all day: measure the wake distance before you design around it, and assume every optional setting is off.