WhereIWas Location Timeline

Mechanism — WhereIWas: Location Timeline — iOS 17 or later

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.

What the app listens to, and what each signal is worth
SignalWhat it saysCost
Motion activityStill, walking, running, cycling or in a vehicle, with a confidence of low, medium or highfree
PedometerSteps taken. Ten of them count as a hint worth waking the receiver forfree
Significant changeYou have moved roughly 500 m. Also fires on arrival, and relaunches a terminated appfree
VisitsiOS thinks you arrived somewhere or left it. Also relaunches a terminated appfree
TimersA stillness countdown or a probe window elapsedfree
GPS fixA position, its accuracy, a speed and a course8–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 four tracking phases and the transitions between them Stationary leads to Probing on a hint, a significant change or a visit. Probing leads to Moving on two fixes at or above 0.7 metres per second or a confident moving activity, and back to Stationary after 45 seconds with no evidence of movement. Moving leads to Just stopped after 120 seconds of stillness seen twice. Just stopped leads back to Moving on a fix at speed or a confident activity, and on to Stationary after five minutes in which nothing moves. Stationary receiver off · listening for free Probing best accuracy · up to 45 s Moving profile follows your speed Just stopped 50 m filter · up to 5 min a hint, a significant change or a visit two fixes ≥ 0.7 m/s, or a confident activity 120 s of stillness, seen twice, not once 45 s and no sign of movement 5 minutes and nothing moves a fix at speed, or a confident activity
Green means the receiver is on; the pale green of Just stopped means it is on but rationed. A fifth phase, Off, sits outside the loop: switching tracking off leaves any phase for it, switching it on — including after a relaunch — enters Probing.

The diagram draws the loop. The full set of edges is short enough to write down:

Every transition
FromOnTo
Stationarya moving activity at medium confidence or betterMoving
Stationaryten steps, a significant change, a visit, or a low-confidence movementProbing
Probingtwo consecutive fixes at 0.7 m/s or more, or a confident moving activityMoving
Probing45 s with no evidence of movementStationary, or Just stopped if a trip was running
Movingspeed crosses a tierMoving, new profile
Moving120 s of stillness, corroborated by a second readingJust stopped
Moving120 s of stillness on a single readingProbing
Just stoppeda fix at speed, or a confident moving activityMoving
Just stoppeda hint, a significant change or a visitProbing
Just stopped5 minutes in which nothing movesStationary
anytracking switched offOff

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.

Phases and receiver state over half an hour For the first three minutes the phase is Stationary and the receiver is off. The first steps open a 45-second probe, then the phase is Moving for sixteen minutes. Movement ends at minute twenty, but the phase stays Moving for the two minutes of the stillness countdown, then Just stopped for five minutes with the receiver still on, and only at minute twenty-seven does it return to Stationary and the receiver go off. Phase Stationary Moving Just stopped Stationary GPS receiver 0 6 12 18 24 30 min ten steps you stop; the trip ends 120 s later
receiver off probing, best accuracy moving, profile by speed just stopped, 50 m filter
The receiver runs for 24 of these 30 minutes, and 16 of them are the trip itself. The 45-second probe at the start and the five-minute watch at the end are what the reliability costs.

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.

  1. 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.
  2. 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.
  3. 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.
  4. …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.
  5. 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.

Accuracy and distance filter by activity and speed
Activity and speedAccuracyFilter
WalkingBest10 m
Running or cycling, below 12.5 m/sBest20 m
DrivingNavigation50 m
Unknown, no speedBest10 m
Unknown, at or above 2.5 m/sBest20 m
Walking or unknown, at or above 7 m/sNavigation50 m
Running or cycling, at or above 12.5 m/sNavigation50 m
ProbingBest0 m
Just stoppedHundred metres50 m
Stationaryno 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:

  1. Invalid accuracy A horizontal accuracy of zero or less means iOS has no idea. Dropped.
  2. 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.
  3. Future timestamp More than five seconds ahead of now. Dropped.
  4. 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.
  5. Out of order Not later than the last accepted fix. Dropped.
  6. Duplicate Within the duplicate distance of the previous fix — by default zero, meaning identical coordinates.
  7. 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.

What each sample holds
GroupFields
Positionlatitude, longitude, altitude, timestamp
Qualityhorizontal accuracy, vertical accuracy, speed accuracy, source (GPS fix, significant change or visit)
Motionspeed, course, detected activity, confidence of that detection
Contextphase 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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