How it works
A tracker that switches its own receiver off is asking to be trusted about when it switches it back on. This page is that answer in full: the signals, the machine they drive, the thresholds, the checks a fix has to pass, and what happens when iOS kills the app anyway. Every number here is a default you can change.
Signals
Six inputs, one of them expensive.
Everything the app decides comes from the list below. Five of the six are served by the motion coprocessor or by the system's own low-power location machinery, which run whether or not an app asks them to — reading them costs essentially nothing. The sixth is the GPS receiver, and it is the entire battery bill.
| Signal | What it says | Cost |
|---|---|---|
| Motion activity | Still, walking, running, cycling or in a vehicle, with a confidence of low, medium or high | free |
| Pedometer | Steps taken. Ten of them count as a hint worth waking the receiver for | free |
| Significant change | You have moved roughly 500 m. Also fires on arrival, and relaunches a terminated app | free |
| Visits | iOS thinks you arrived somewhere or left it. Also relaunches a terminated app | free |
| Timers | A stillness countdown or a probe window elapsed | free |
| GPS fix | A position, its accuracy, a speed and a course | 8–12 %/h at best accuracy |
Two of those signals — significant changes and visits — are also the only reason a terminated app ever runs again, so they stay registered for as long as tracking is on, even while nothing else is. See termination and reboot.
The machine
Five phases, and the arrows between them.
The decision itself is a pure state machine: signals in, effects out, no clock of its own and no framework underneath it — which is why the whole of it can be driven by tests, timers included. Each phase says exactly what the receiver is doing.
The diagram draws the loop. The full set of edges is short enough to write down:
| From | On | To |
|---|---|---|
| Stationary | a moving activity at medium confidence or better | Moving |
| Stationary | ten steps, a significant change, a visit, or a low-confidence movement | Probing |
| Probing | two consecutive fixes at 0.7 m/s or more, or a confident moving activity | Moving |
| Probing | 45 s with no evidence of movement | Stationary, or Just stopped if a trip was running |
| Moving | speed crosses a tier | Moving, new profile |
| Moving | 120 s of stillness, corroborated by a second reading | Just stopped |
| Moving | 120 s of stillness on a single reading | Probing |
| Just stopped | a fix at speed, or a confident moving activity | Moving |
| Just stopped | a hint, a significant change or a visit | Probing |
| Just stopped | 5 minutes in which nothing moves | Stationary |
| any | tracking switched off | Off |
A stop
Half an hour, drawn to scale.
One trip is enough to show where the time goes: three minutes of nothing, a probe that lasts three quarters of a minute, a trip, and a stop that takes seven minutes to be believed.
Set off again at minute 25 — inside the watch — and the departure is on the trail from its first 50 m, because the receiver never stopped. Set off at minute 35, and the app is waiting on the motion coprocessor or on a significant change instead, which is the expensive case described under termination.
A phone that never moved never pays for that watch: it is only armed once a trip has actually been running. Sitting still all morning costs nothing but the free signals.
Why not simpler
Five rules a day in the field wrote.
Each of these makes the machine more complicated than it looks like it needs to be, and each replaced something simpler that failed on a recorded day.
- Two fixes, not one CoreLocation sometimes puts a large speed on a fix that has not moved. One such reading used to be enough to declare a trip and switch to the driving profile, so it now takes two consecutive fixes at or above 0.7 m/s; any slower or speed-less fix breaks the streak. The same confirmation gates a change of profile, so a single spurious reading cannot widen the distance filter mid-walk.
- A timer that fires must have been fed Arming the 120-second stillness countdown takes one still reading; reaching the end of it takes a second one, arriving while it ran. Without that, a timer firing says only that nothing arrived since it was armed — which is exactly what a suspended app looks like. One recorded stillness timer fired 1224 seconds after being armed. Uncorroborated, it opens a probe instead of ending the trip.
- A confirmed stop silences the classifier's flapping While a phone sits still, the activity classifier alternates between "stationary, low confidence" and "unknown, high confidence". Treating each flap as a reason to look woke the receiver 259 times in one recorded day. Once a stop is confirmed, a confident unknown no longer reopens a probe — until a step, a significant change, a visit, a moving activity or any fix with real speed clears it.
- …but three cases are deliberately excluded from that A phone on a car seat is genuinely stationary to the coprocessor while the car drives on, so a stop is never confirmed from inside a trip. A probe window that saw no fix at all — a garage, a basement, a cold receiver — is absence of evidence, not evidence of stillness. And a measured GPS speed refutes the guess outright. Absence of evidence is never promoted to evidence.
- Significant changes and visits never declare movement They are accurate to about 500 m and they fire on arrival as well as departure. They open a probe; a real fix decides. Nothing but GPS speed or the classifier is allowed to say "moving".
Receiver profiles
The receiver is configured, not merely switched on.
While moving, the app picks an accuracy and a distance filter from the activity and the speed. The distance filter is what actually rations fixes — asking iOS for a coarser accuracy mostly does not, since it delivers whatever its receiver has already computed. In one recorded day a request for kilometre accuracy still returned fixes with a median accuracy of 6 m.
| Activity and speed | Accuracy | Filter |
|---|---|---|
| Walking | Best | 10 m |
| Running or cycling, below 12.5 m/s | Best | 20 m |
| Driving | Navigation | 50 m |
| Unknown, no speed | Best | 10 m |
| Unknown, at or above 2.5 m/s | Best | 20 m |
| Walking or unknown, at or above 7 m/s | Navigation | 50 m |
| Running or cycling, at or above 12.5 m/s | Navigation | 50 m |
| Probing | Best | 0 m |
| Just stopped | Hundred metres | 50 m |
| Stationary | no updates at all | |
Speed overrides a slow label at 7 m/s, because walking at 9 m/s is a vehicle with a wrong label. Cycling and running are not slow labels: 7 m/s is 25 km/h, a speed any cyclist holds on the flat and doubles downhill, and applying that bar to them put entire rides on the driving profile. They keep their own filter up to 12.5 m/s — 45 km/h — above which a bicycle label is wrong often enough (a bike on a car rack, a classifier that has not caught up with the drive that just started) that speed wins again.
The filter
Every fix is doubted before it is kept.
A position arriving from CoreLocation is a claim, not a fact. Seven checks run in order, and the first failure decides — no fix is written after failing one:
- Invalid accuracy A horizontal accuracy of zero or less means iOS has no idea. Dropped.
- Poor accuracy Worse than 50 m. Dropped — a point you cannot place within a city block is worse than a gap, because it looks like a position.
- Future timestamp More than five seconds ahead of now. Dropped.
- Stale Older than 30 seconds. CoreLocation replays cached fixes the moment updates restart, so this check is what stops a departure being drawn from where you were an hour ago.
- Out of order Not later than the last accepted fix. Dropped.
- Duplicate Within the duplicate distance of the previous fix — by default zero, meaning identical coordinates.
- Cached repeat Latitude, longitude and accuracy exactly equal to one of the last ten accepted fixes, with no valid speed. iOS replays a cached network fix with a fresh timestamp, which the previous-fix check misses whenever a real fix lands in between. A twin older than ten minutes no longer counts, so a receiver whose whole input is one repeated solution is never silenced for good.
The ~500 m positions that arrive with a significant change or a visit skip all of this — they are not GPS fixes, they carry no speed, and the accuracy limit would simply delete them. They are stored with their source recorded, so a reader can always tell one from a real fix.
Every threshold above is a slider in Settings, and the app runs exactly the table it shows you. With the audit trail on, each check is recorded with the value measured and the threshold it was measured against — see the audit trail.
A stored point
Enough context to argue with a track.
A coordinate on its own cannot be checked. Each accepted fix is annotated, at the moment it is written, with everything needed to interpret it afterwards.
| Group | Fields |
|---|---|
| Position | latitude, longitude, altitude, timestamp |
| Quality | horizontal accuracy, vertical accuracy, speed accuracy, source (GPS fix, significant change or visit) |
| Motion | speed, course, detected activity, confidence of that detection |
| Context | phase the app was in, receiver profile in force, battery level and charging state, recording session |
Both export formats carry the lot: GPX 1.1 puts speed, course, accuracy, activity and battery in an extension namespace that ordinary mapping tools ignore, and the JSON export is the whole record with ISO-8601 timestamps. You choose the span — today, the last seven days, a date range, one session, or everything — and the map screen draws the same selection before you send it.
Termination
iOS will kill the app. The contract is what happens next.
Memory pressure, a crash, a reboot: the process goes away without warning, and the only thing that brings it back is a registration made before it did. Four rules cover that.
- Two registrations stay armed Significant location changes and visits are monitored for as long as tracking is enabled, in every phase including Stationary. Both relaunch a terminated app, with a flag in the launch options saying why.
- Relaunch is handled before any screen exists At launch, before the first view is created, the app reads its persisted "tracking on" flag and — in the same run-loop turn, not after an await — creates the location manager, re-arms monitoring, starts the motion updates and enters Probing. The event that caused the relaunch is only delivered to a manager that exists by the time launch returns, and the app has a few seconds of background time to get there.
- The background session is held throughout A background activity session is taken when tracking is switched on and released only when it is switched off — not around each trip. It can only be started from the foreground, so releasing it at every stop meant the next trip, which begins in the background, ran with no session at all. It is also what puts the blue indicator in the status bar, and the reason there is no setting to hide it: hiding it is a documented way for iOS to suspend the app.
- The system's own pausing is off iOS will silently pause location updates when it decides you are stationary, and it has no obligation to resume them until the app is foregrounded. That is exactly the decision this app makes itself — with a way back. So automatic pausing is disabled, and when a pause or resume is reported anyway it goes into the audit trail, precisely because the assumption is what is in doubt.
And if the app is relaunched for a location event while tracking is off — a stale registration the system still held — it unregisters on the spot, so iOS stops waking it.
What that still cannot cover
- Between a reboot and the first unlock, nothing runs. File protection keeps the database unreadable until the device is unlocked once. The registrations survive the reboot; the first event after first unlock brings the app back.
- Force quit stops everything. Swiped out of the app switcher, an app gets no background events at all until it is opened again. The status screen warns when the last fix is more than 30 minutes old.
- A wake from termination costs up to ~500 m. That is the resolution of a significant change. Within five minutes of a stop the watch covers it; beyond that, it is what the first movement costs.
- Without motion permission the app leans on GPS. Probing windows double in length, tracking still works, the battery pays for it.
- The maintenance task is a suggestion. Low Power Mode or Background App Refresh off may mean it never runs; the purge also happens at launch and on demand from Settings.
Audit trail
Optional, and the only honest answer to "why is there a gap?"
Turned on, the app records not just what it kept but what it saw and what it decided. Three kinds of rows:
- Data received — every fix, significant change and visit with its raw coordinates, accuracy, speed and timestamp; every motion report with its confidence.
- Tests performed — each check of the filter with the value measured, the threshold, and a verdict of passed, failed, skipped or not applicable. The trail re-runs the real filter to produce them, and a test asserts the two agree across a grid of inputs, so the trail cannot describe a decision the filter did not make.
- Decisions taken — state transitions with their cause, every effect executed, profile changes, permission changes, writes, purges and exports.
A heartbeat row is written every 30 seconds while the receiver is running. It records nothing about tracking — only that the process was executing. Without it an export cannot tell "the receiver reported nothing" from "the app was suspended", which is the difference between a bug and an operating system.
No event stores prose. Each is a stable code with its parameters
(fix.rejected, then poorAccuracy and
88.0), formatted independently of locale; the sentence
you read is built at display time in your language. So a trail
exported on a French phone and read on an English one says the same
thing, and nothing has to be parsed back out of English.
Off by default, and free while off: a disabled trail never even builds the event. On, events are buffered and written in batches off the main thread. It keeps its own retention — seven days, against thirty for the samples — because it turns over far faster. Exports are gzipped by default: a week of trail is tens of megabytes of text and deflates about sixteen to one.
Battery
The arithmetic, stated plainly.
GPS at best accuracy costs roughly 8–12 % of an iPhone battery per hour. Motion activity, the pedometer, significant changes and visits are served by the coprocessor and are, for practical purposes, free. Everything in the design follows from those two facts: the receiver runs in Moving, Probing and Just stopped, and nowhere else; its profile is scaled to your speed; 120 seconds of hysteresis stops it flapping at traffic lights; and a stop pays up to five minutes on a wide filter rather than lose the restart.
Expect someone who walks three hours and drives two over a twelve-hour day to spend roughly 40–50 % of the battery on tracking. A day at a desk costs almost nothing, because almost nothing runs.
Storage
One database file, four tables, no server.
Samples, recording sessions, state transitions and audit events, in a SQLite database inside the app's own container. Fixes are written in batches of twenty and flushed on every state change, on going to the background, on switching tracking off and before any export, so an unexpected termination costs at most the handful of points still in the buffer.
Each sample carries a monotonic sequence number and a copy of its session id rather than a link to it — deliberately, so purging tens of thousands of rows never touches the session records. Retention is thirty days by default for samples and seven for the audit trail; the purge runs from a background maintenance task, at every launch, and from Settings on demand.
Nothing is uploaded, because there is nowhere to upload it to: no account, no server, no analytics, no third-party dependency, no networking code in the app at all. The privacy policy says the same thing in the form a policy has to take.
Reading the code
Everything on this page is in the repository, and the file to start
from is ARCHITECTURE.md: the module map, the state
machine, the profile table and the relaunch contract, with the
reasoning that is only summarised here.
- Source code
- github.com/glandais/WhereIWas
- Questions and bug reports
- github.com/glandais/WhereIWas/issues