Tracking lifecycle
Once start() is called, the SDK is
always in exactly one of two motion states — MOVING or STATIONARY —
and almost everything else in the SDK (GPS power draw, fix density, which
native services stay armed) is a consequence of that one bit. This is the
core mental model for the whole tracker: most “why is battery draining” or
“why did I get no points for 10 minutes” questions are really “which state
was the device in, and what wakes it out of stationary.”
The split exists because full-rate GPS and battery life are directly opposed:
- MOVING — GPS runs at full rate, spaced by the (speed-elastic)
distanceFilteratdesiredAccuracy. This is expensive but necessary: a route is only useful if it’s dense enough to reconstruct. - STATIONARY — GPS is, for practical purposes, asleep. A low-power
keep-alive stream (see
stationaryKeepAlive) and a stationary geofence around the last stopped fix are what’s left running. There is no reason to keep drawing moving-grade power for a device that hasn’t moved.
The state machine
stateDiagram-v2
[*] --> STATIONARY
STATIONARY --> MOVING: activity recognition
STATIONARY --> MOVING: speed threshold
STATIONARY --> MOVING: stationaryRadius exit
STATIONARY --> MOVING: changePace(true)
MOVING --> STATIONARY: still + stopTimeout → stop record
A tracking session starts in STATIONARY (there’s no fix yet to prove movement). Four independent signals can trigger STATIONARY → MOVING, and any one of them is sufficient:
- Activity recognition says a moving-type activity (
in_vehicle,on_bicycle,on_foot,running,walkingby default — seetriggerActivities) is happening, at or above the platform’s confidence bar. - Speed threshold — a raw fix implies real ground speed, independent of
activity recognition. This is also the fallback path when activity
recognition is disabled (
disableMotionActivityUpdates) or has gone stale. - Stationary-geofence exit — a raw fix whose distance from the stop
anchor clears
stationaryRadiuswakes tracking immediately, without waiting on activity recognition at all. changePace(true)— an explicit app-driven override of the automatic machine.
The reverse transition, MOVING → STATIONARY, has exactly one path: a
confident still verdict has to be sustained for
stopTimeout before the
machine commits. Committing writes a stop record (an is_moving:false
onMotionChange event),
re-arms the stationary geofence around the stop point, and drops GPS to the
stationary power profile.
Stop detection
Getting to STATIONARY is deliberately harder than getting to MOVING, because a false stop costs GPS density on a genuinely still-moving route, while a missed stop only costs a few extra minutes of moving-power draw. The mechanics:
- Countdown, not a snapshot. A confident
stillverdict arms astopTimeoutcountdown rather than stopping immediately. If the countdown is already armed, a freshstillverdict does not restart it — only the original arm time matters, so the timer can’t be indefinitely postponed by repeatedstillupdates. - Two vetoes can cancel a pending stop, both independent of activity
recognition: a raw displacement of more than 50 m from the point where the
countdown was armed cancels it outright (the device evidently never
actually stopped), and — once the device is parked — a raw fix whose
distance from the stop anchor clears
stationaryRadiuswakes tracking again. That second check runs ahead of the normal accuracy filter, because the stationary keep-alive stream is intentionally coarse. - Motion classification is tri-state, not boolean. Only a confident
stillverdict is allowed to arm a stop; low-confidencewalking/unknownreadings are treated as genuinely uncertain rather than rounded to “not moving.” A gap in raw fixes triggers recovery logic, never a stationary decision by itself — the machine never infers “stopped” from silence alone.
Wake sources
While STATIONARY, GPS is asleep but the platform isn’t idle — each side keeps
a small set of native services armed specifically to detect departure (or
survive a kill) without full-rate GPS. The table’s “Stationary geofence”
(Android) and “Wake region” (iOS) rows are the same underlying concept
realized per-platform: both are a
stationaryRadius-sized
region around the stop anchor, used to detect departure and, on iOS, to
relaunch the app after a kill — see
stationaryRadius for
how the one config key drives both platforms’ regions.
| Platform | Source | Role |
|---|---|---|
| Android | Foreground service | Always running while tracking is enabled; restarts itself via onTaskRemoved if the task is swiped away. |
| Android | Play activity recognition | Drives the moving-activity trigger above; falls back to speed-based detection if it goes stale. |
| Android | Stationary geofence | Fires the stationaryRadius exit trigger. |
| Android | Boot receiver | Restarts the service on BOOT_COMPLETED/LOCKED_BOOT_COMPLETED when startOnBoot is set and tracking was enabled. |
| iOS | Significant-location-change (SLC) | Always registered; a coarse relaunch trigger after an OS-initiated termination. |
| iOS | Wake region | A circular region that rolls along the route while moving and parks at the stop anchor while stationary; survives a reboot; the one location service Apple relaunches even after a user force-quit. |
| iOS | liveUpdates auto-resume | The session stream is never stopped while tracking is enabled — it auto-pauses as the parked keep-alive and auto-resumes in the background the moment movement is detected. |
| iOS | Session recreation at relaunch | The session object is rebuilt synchronously at process launch, continuing the authorization grant it held before the process was evicted. |
| iOS | CLServiceSession (iOS 18+) | A standing authorization-need declaration held for the stream’s lifetime, reinforcing grant preservation across suspension/termination/relaunch. No-op below iOS 18. |
The iOS session engine
On iOS 17+, useSessionEngine
(default true) selects a modern delivery path — CLLocationUpdate.liveUpdates
plus CLBackgroundActivitySession — built specifically so background delivery
survives suspension without relying solely on SLC bursts. It exists as a
remote-config kill-switch: set it false to force the legacy
CLLocationManager path (devices below iOS 17 always use the legacy path
regardless of this flag). The deliberate cost of the session engine is that
the background-location indicator stays visible in the status bar for as
long as tracking is enabled — a live session implies it, and it’s the price
of reliable auto-resume.
onMotionChange fires at every transition
onMotionChange is the one
event that fires at both edges of the state machine — isMoving: true on
STATIONARY → MOVING, isMoving: false on MOVING → STATIONARY (including
changePace()-forced
transitions). Watch for the very first motionchange of a tracking session:
it fires from the initial moving-probe ahead of any fix, so location can
be absent (Android) or null (iOS) — don’t assume it’s always populated.
Kill, force-quit, and reboot
The wake sources above double as the SDK’s resilience against the app being
killed. Android’s foreground service plus onTaskRemoved plus
startOnBoot restart tracking
deterministically after most kill paths and a reboot. iOS has no equivalent
guaranteed hook, but with the session engine (default on iOS 17+) it doesn’t
need one: CLServiceSession plus synchronous session recreation at launch
relaunch the app in roughly a second after either an OS-initiated
termination or a user force-quit — device-verified across three
force-quit deaths with roughly a second of tracking loss. The rolling wake region
is a secondary, belt-and-braces relaunch trigger on top of that (and the
primary one on the pre-17 legacy path, which has no session to recreate).
The full platform-by-platform matrix — what survives which kill path, and
how long relaunch takes — lives in the
Boot & killed-app behavior guide.
Stationary heartbeat
While STATIONARY (or MOVING), a
heartbeatInterval
timer keeps firing regardless of motion state, carrying the last known
location. It’s not a wake source by itself, but each firing also nudges the
stop-timeout bookkeeping — see the
heartbeat event for the
payload shape.