Tracking
app/lib/tracking/ is the load-bearing part of the app. A race only works if the phone keeps
sampling GPS and sending fixes to the server while it sits in a pocket with the screen off.
Everything on this page exists for that one requirement.
The short version: a second Dart isolate runs inside an Android foreground service (on iOS, a
headless Flutter engine kept alive by background location). It reads the GPS at about 1 Hz,
every 2 s sends the batch to the UI isolate for display, writes it to a sembast queue on disk,
and drains that queue to POST /api/races/:id/positions. The UI isolate never uploads a fix.
Two isolates
Section titled “Two isolates”A Dart isolate is a thread of execution with its own heap and its own event loop. Isolates share no memory; they talk only by sending messages, and the values are copied. If that sounds like a BEAM process, it is the right picture, with these differences:
- An isolate is heavyweight (an OS thread and a heap), so you have a handful, not thousands.
- There are no links, monitors or supervisors. If the service isolate dies, nothing restarts it; the OS-level service is what keeps it alive.
- What you can send is limited to plain data (numbers, strings, lists, maps, and a few more).
That is why every message on this page is a
Mapand not a class. - Here the second isolate is not spawned by Dart code.
flutter_foreground_taskstarts a separate Flutter engine from native code and runs a Dart entry point in it, so the two isolates live in different engines. That is also why the service isolate has no Riverpod, no router and no widgets: none of the app’smain()ran there.
flowchart LR
subgraph UI["UI isolate (main engine)"]
RN[RaceNotifier]
RT[ForegroundRaceTracker]
LN[LocationNotifier<br/>diagnostics only]
LS[LocationService<br/>start / stop]
end
subgraph SVC["Service isolate (foreground-service engine)"]
TH[LocationTaskHandler]
SRC[RealLocationSource<br/>or SimulatedLocationSource]
Q[(FixQueue<br/>sembast file)]
UP[PositionUploader]
HC[HttpPositionClient]
end
GPS((GPS)) --> SRC --> TH
RN --> RT --> LS
LS -- "saveData: race id, base URL, token, queue path" --> TH
LS -- "sendDataToTask: sim commands" --> TH
TH -- "sendDataToMain: batch / upload" --> RT
TH -- "sendDataToMain: batch" --> LN
TH --> Q --> UP --> HC -- "POST /api/races/:id/positions" --> API[(Phoenix)]
There are three channels between the isolates, each one-directional:
| What | Direction | Mechanism | Code |
|---|---|---|---|
| Start parameters | UI → service, before start | FlutterForegroundTask.saveData / getData (key-value storage the plugin backs with SharedPreferences) |
app/lib/tracking/location_service.dart:329-348, read at app/lib/tracking/location_service.dart:54-59 and app/lib/tracking/location_service.dart:77-91 |
| Batches and upload reports | service → UI, every 2 s | FlutterForegroundTask.sendDataToMain |
app/lib/tracking/location_service.dart:150, app/lib/tracking/location_service.dart:179-183 |
| Simulator commands | UI → service, any time | FlutterForegroundTask.sendDataToTask |
app/lib/tracking/location_service.dart:380-386, received at app/lib/tracking/location_service.dart:205-209 |
The service → UI path is a Dart SendPort registered by name. main() calls
FlutterForegroundTask.initCommunicationPort() (app/lib/main.dart:14), which creates a
ReceivePort and registers its SendPort in IsolateNameServer under a fixed name; the
service isolate looks that name up and sends to it. That is Process.register(self(), :ui)
followed by send(:ui, msg) from another process. If the UI isolate is gone (Android killed
the activity but kept the service), the lookup finds nothing and the message is dropped, and
the service carries on uploading regardless.
Starting the service
Section titled “Starting the service”LocationService (app/lib/tracking/location_service.dart:223-387) is the UI-side handle. It
is a singleton through a factory constructor that always returns the same instance
(app/lib/tracking/location_service.dart:224-228), the Dart idiom for what Kotlin does with
object.
startBackgroundTracking does three things in order:
_ensureInitialized();await requestNotificationPermission();if (await FlutterForegroundTask.isRunningService) return;
await FlutterForegroundTask.saveData( key: kServiceFakeGpsKey, value: fakeGps);// ...if (raceId != null && apiBaseUrl != null && token != null) { final dir = await getApplicationSupportDirectory(); final target = RaceUploadTarget( raceId: raceId, apiBaseUrl: apiBaseUrl, token: token, queuePath: '${dir.path}/fix_queue.db', ); await FlutterForegroundTask.saveData( key: RaceUploadTarget.keyRaceId, value: target.raceId); // ... base URL, token, queue path}
final result = await FlutterForegroundTask.startService( notificationTitle: 'FootRace', notificationText: fakeGps ? 'SIM · tracking your race' : 'Tracking your race', callback: startLocationCallback,);(app/lib/tracking/location_service.dart:325-358)
_ensureInitializedconfigures the plugin once: the Android notification channel, and a repeat event everykFlushIntervalMs(2000 ms) with a wake lock (app/lib/tracking/location_service.dart:289-308).- It writes everything the service will need into the plugin’s storage, because the service
isolate cannot read Riverpod, the keychain or
AppConfig.RaceUploadTarget(app/lib/tracking/upload/race_upload_target.dart:4-24) names the four keys. Without a race (the dev diagnostics screen) the keys are removed and the service only reports fixes. - It starts the service with
startLocationCallbackas the entry point.
The entry point has to be a top-level function the AOT compiler cannot tree-shake away, since nothing in Dart calls it; native code does:
@pragma('vm:entry-point')void startLocationCallback() { FlutterForegroundTask.setTaskHandler(LocationTaskHandler());}(app/lib/tracking/location_service.dart:26-29). The pragma plays the role of a ProGuard
-keep rule or Swift’s @objc dynamic.
stopBackgroundTracking stops the service and deletes the four keys
(app/lib/tracking/location_service.dart:364-376). The comment there says why: “The token must
not outlive the race in the service’s plain storage”. The plugin’s storage is ordinary
SharedPreferences, not the keychain, so the JWT sits unencrypted for the length of a race.
Who starts it
Section titled “Who starts it”For a race, RaceNotifier starts tracking gpsLead (3 s) before startsAt so the first fix
is ready at the gun (app/lib/features/race/race_notifier.dart:114,
app/lib/features/race/race_notifier.dart:274-289). _ensureTracking reads the token and
config in the UI isolate, writes the race id to ActiveRaceStore so a killed app can resume,
and calls the tracker (app/lib/features/race/race_notifier.dart:300-317):
final token = await ref.read(tokenStoreProvider).read() ?? '';final config = ref.read(appConfigProvider);await ref.read(activeRaceStoreProvider).write(arg);await _tracker.start( raceId: arg, apiBaseUrl: config.apiBaseUrl, token: token, fakeGps: config.fakeGps,);_tracker is a RaceTracker (app/lib/tracking/race_tracker.dart:10-24), an interface with
start, stop and a messages stream, so race tests run without a service.
ForegroundRaceTracker registers a data callback and forwards every map from the service
into that stream (app/lib/tracking/race_tracker.dart:26-68).
The other caller is LocationNotifier (app/lib/tracking/location_provider.dart:18-80), which
drives the dev diagnostics screen in lib/dev/. It owns a TrackingState: whether tracking
runs, the last fix from a foreground Geolocator stream and the last fix from the service.
Comparing those two is how the proof of concept showed the service keeps sampling when the UI
stream stops. Races do not use LocationNotifier or TrackingState.
Inside the service: LocationTaskHandler
Section titled “Inside the service: LocationTaskHandler”LocationTaskHandler extends TaskHandler (app/lib/tracking/location_service.dart:37-221) is
the plugin’s callback class: onStart, onRepeatEvent, onReceiveData, onDestroy. Think of
an Android Service with onStartCommand and a Handler posting a runnable every 2 s, or a
GenServer with init/1, a :tick from :timer.send_interval/2, handle_info/2 and
terminate/2.
onStart picks a source and opens the upload pipeline:
final source = fake ? SimulatedLocationSource( paceSecondsPerKm: pace ?? kDefaultSimPaceSecondsPerKm) : RealLocationSource();_source = source;await _openUploadPipeline();_subscription = source.fixes.listen(_onFix, onError: (Object e) { /* ... */ });await source.start();(app/lib/tracking/location_service.dart:62-72)
_onFix appends each fix to two lists: _buffer (maps for the UI) and _pendingEnqueue
(GpsFix objects for the queue), and tracks the largest gap between fixes
(app/lib/tracking/location_service.dart:110-129).
onRepeatEvent, every 2 s, is the flush:
final batch = <String, Object>{ 'type': 'batch', 'fixes': fixes, 'totalFixes': _totalFixes, 'maxGapMs': _maxGapMs, 'sinceLastFixMs': sinceLastFixMs, if (source is SimulatedLocationSource) 'sim': source.snapshot.toMap(),};_buffer.clear();// ...FlutterForegroundTask.sendDataToMain(batch);_persistAndUpload();// ...FlutterForegroundTask.updateService( notificationText: '$prefix$pace · $_totalFixes fixes · max gap ${(_maxGapMs / 1000).toStringAsFixed(1)}s',);(app/lib/tracking/location_service.dart:132-161)
The repeat timer is the flush cadence, not the sample rate; the GPS stream sets that. The notification text is rewritten each flush with the current pace, fix count and worst gap, which doubles as a field diagnostic.
source is SimulatedLocationSource in a map literal is a type test that also promotes
source to that type inside the if, the way Kotlin smart casts work.
Fixes: GpsFix and the sources
Section titled “Fixes: GpsFix and the sources”GpsFix (app/lib/tracking/gps_fix.dart:4-49) is a plain value: latitude, longitude,
accuracy, speed, altitude, timestamp. It has three wire formats, which is worth knowing when
you read logs:
| Where | Keys | Code |
|---|---|---|
| Service → UI batch | lat, lng, accuracy, speed, altitude, timestamp, gapMs |
app/lib/tracking/location_service.dart:120-128, decoded by GpsFix.fromMap at app/lib/tracking/gps_fix.dart:31-39 |
| sembast record | lat, lng, …, plus raceId, createdAt, uploadedAt |
app/lib/tracking/upload/fix_queue.dart:58-68 |
| Upload body | latitude, longitude, timestamp (UTC ISO 8601), accuracy, altitude, speed |
app/lib/tracking/upload/fix_queue.dart:23-30 |
The upload body is what Racing.Positions.Ingest.parse/1 reads
(backend/lib/racing/positions/ingest.ex:37-47). heading is accepted by the backend but the
app does not send it.
LocationSource (app/lib/tracking/location_source.dart:11-15) is the interface both sources
implement. RealLocationSource wraps Geolocator.getPositionStream with best accuracy and
distanceFilter: 0, because “at race pace a 10 m filter would drop most 1 Hz fixes”
(app/lib/tracking/location_source.dart:17-45).
The simulator
Section titled “The simulator”SimulatedLocationSource (app/lib/tracking/simulated_location_source.dart:69) emits one fix
per second along a heading (east from Tulln by default) or along a route, at a set pace, with
Gaussian jitter. It runs inside the service isolate, so everything downstream (batches,
notification, queue, uploads) is the real code path. It turns on with --dart-define=FAKE_GPS=true,
which AppConfig.fakeGps ignores in release builds (app/lib/core/config/app_config.dart:29).
It takes commands from the dev overlay (app/lib/dev/sim_gps_controller.dart) as
{'type': 'sim', 'cmd': ..., 'value': ...} maps through sendDataToTask:
switch (data['cmd']) { case 'pause': pause(); case 'resume': resume(); case 'set_pace': if (value != null) setPace(value); case 'sprint': sprint(duration: Duration(seconds: value?.round() ?? 10)); case 'lose_signal': loseSignal(Duration(seconds: value?.round() ?? 10)); case 'bad_accuracy': badAccuracy(Duration(seconds: value?.round() ?? 10));}(app/lib/tracking/simulated_location_source.dart:155-173)
lose_signal keeps the runner moving but emits nothing, so the server sees a jump on
resume; bad_accuracy emits fixes with 80 m accuracy
(app/lib/tracking/simulated_location_source.dart:188). These reproduce the field failures in
Failure modes on an emulator.
The upload pipeline
Section titled “The upload pipeline”sequenceDiagram
autonumber
participant G as LocationSource
participant H as LocationTaskHandler
participant Q as FixQueue (sembast)
participant U as PositionUploader
participant S as POST /api/races/:id/positions
participant UI as RaceNotifier (UI isolate)
loop ~1 Hz
G->>H: GpsFix
end
Note over H: onRepeatEvent, every 2 s
H->>UI: {type: batch, fixes}
H->>Q: enqueue(raceId, fixes)
H->>U: drain()
loop until queue empty or failure
U->>Q: pending(limit 50)
U->>S: {positions: [...]}
alt 200
S-->>U: {race, competitors, competitor}
U->>Q: markUploaded
else network error or 5xx
U-->>H: report error, backoff 1,2,4..30 s
else 409 / 403 / 401
U-->>H: stop for good
else 422
U->>Q: markUploaded (drop batch)
end
end
H->>UI: {type: upload, pending, uploaded, reply, error, stop}
FixQueue (sembast)
Section titled “FixQueue (sembast)”FixQueue (app/lib/tracking/upload/fix_queue.dart:38-145) is a durable, ordered queue per
race on sembast, a pure-Dart embedded document store. Pure
Dart is the point: it opens inside the service isolate without any plugin.
enqueuewrites all fixes of a flush in one transaction (app/lib/tracking/upload/fix_queue.dart:54-71).pendingreturns the oldest un-uploaded fixes for a race, sorted by fix timestamp then insertion key, at mostlimit(app/lib/tracking/upload/fix_queue.dart:74-87). The sembastFinderwithFilter.andandSortOrderreads like an Ecto query.markUploadedsetsuploadedAtinstead of deleting (app/lib/tracking/upload/fix_queue.dart:97-104);pruneUploadeddeletes those older than a day, and runs every time the pipeline opens (app/lib/tracking/location_service.dart:94).clearRacedrops everything for a race once the server says it is over.
The file sits at <application support dir>/fix_queue.db
(app/lib/tracking/location_service.dart:334-339). Fixes survive the service being killed: the
next service start for the same race finds them pending.
PositionUploader
Section titled “PositionUploader”PositionUploader.drain (app/lib/tracking/upload/position_uploader.dart:140-214) takes
batches of 50 from the queue and posts each with HttpPositionClient, a tiny wrapper over the
http package with a bearer header and a 15 s timeout
(app/lib/tracking/upload/position_uploader.dart:26-66). Dio is not used here because it is
configured through Riverpod in the UI isolate.
Status handling, with the backend line that produces each status:
| Response | Uploader does | Backend source |
|---|---|---|
| 2xx | mark batch uploaded, reset backoff, take the next batch | json(conn, payload) at backend/lib/footrace_web/controllers/api/race_controller.ex:63 |
| no response (timeout, offline) | keep pending, back off 1, 2, 4, 8, 16, 30 s | n/a |
| 409 | stop for good (raceOver), queue for the race is cleared |
:race_over at backend/lib/footrace_web/controllers/api/race_controller.ex:70-73 |
| 403 | stop for good (forbidden) |
:not_participant at backend/lib/footrace_web/controllers/api/race_controller.ex:65-68 |
| 401 | stop until the service restarts with a new token | the :authorized_api pipeline in backend/lib/footrace_web/router.ex |
| 422 | drop the batch (mark uploaded), report the error | backend/lib/footrace_web/controllers/api/race_controller.ex:75-87 |
| anything else | keep pending, back off |
The switch that does this is app/lib/tracking/upload/position_uploader.dart:176-201. Dart 3
switch cases do not fall through, so case 409: _stopped = UploadStop.raceOver; ends there,
and the shared return after the switch reports the stop.
Backoff doubles from 1 s to a 30 s cap (app/lib/tracking/upload/position_uploader.dart:216-220).
It is applied at the start of the next drain, so a flush that arrives during the wait only
enqueues.
The sleep function is injectable (app/lib/tracking/upload/position_uploader.dart:101,
app/lib/tracking/upload/position_uploader.dart:117-120), so
app/test/tracking/upload/position_uploader_test.dart checks the backoff sequence without
waiting.
What the UI does with reports
Section titled “What the UI does with reports”RaceNotifier._onTrackerMessage (app/lib/features/race/race_notifier.dart:201-234) handles
both message types. A batch feeds a local distance counter for instant feedback. An upload
carries the server’s last 200 body, the same {race, competitors, competitor} shape the race
channel pushes as raceUpdate, so the screen stays current even without a socket. If that
snapshot says the race is won or over, the race ends. A stop of unauthorized or forbidden
shows “Uploads stopped”.
Permissions: PermissionGate
Section titled “Permissions: PermissionGate”Background location needs more than one prompt, in a platform-defined order.
PermissionGate (app/lib/tracking/permission_gate.dart:108-194) is an AsyncNotifier
whose state is the next missing step:
enum PermissionStep { locationServicesOff, needsLocation, needsAlwaysLocation, deniedForever, needsNotification, suggestBatteryExemption, ready,}(app/lib/tracking/permission_gate.dart:7-28)
_evaluate walks them in order (app/lib/tracking/permission_gate.dart:115-156) and
requestCurrentStep performs whatever the current one asks for, then re-evaluates
(app/lib/tracking/permission_gate.dart:163-187). The battery-optimisation exemption is a
suggestion, offered once and never blocking: canTrack is true at suggestBatteryExemption
(app/lib/tracking/permission_gate.dart:43-45). The platform calls sit behind
PermissionPlatform so tests drive every branch without a device. The permissions screen in
lib/features/permissions/ reads this provider.
LocationService.requestLocationPermissions (app/lib/tracking/location_service.dart:232-251)
is an older, cruder check that the tracker still calls right before starting
(app/lib/tracking/race_tracker.dart:51). It throws if location is off or denied.
Native configuration
Section titled “Native configuration”Android
Section titled “Android”app/android/app/src/main/AndroidManifest.xml holds the parts you would expect from writing a
tracking Service in Kotlin:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /><uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /><uses-permission android:name="android.permission.FOREGROUND_SERVICE" /><uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /><uses-permission android:name="android.permission.POST_NOTIFICATIONS" /><uses-permission android:name="android.permission.WAKE_LOCK" /><!-- ... --><service android:name="com.pravera.flutter_foreground_task.service.ForegroundService" android:foregroundServiceType="location" android:exported="false" />(app/android/app/src/main/AndroidManifest.xml:4-9, app/android/app/src/main/AndroidManifest.xml:37-40)
The <service> is the plugin’s own class. It calls startForeground with the notification,
then boots a Flutter engine and runs startLocationCallback in it. foregroundServiceType="location"
plus FOREGROUND_SERVICE_LOCATION is what Android 14 requires for a location foreground
service, and Android refuses to start one without location permission granted, which is why
even the simulator asks for it (app/lib/tracking/location_provider.dart:31-33).
POST_NOTIFICATIONS (Android 13+) only decides whether the notification is visible; the
service runs either way (app/lib/tracking/location_service.dart:253-262).
iOS has no foreground services. What keeps an app running with the screen locked is an active
CLLocationManager with background location allowed. app/ios/Runner/Info.plist:69-81 has:
NSLocationWhenInUseUsageDescriptionandNSLocationAlwaysAndWhenInUseUsageDescription(the prompt strings).UIBackgroundModeswithlocationandfetch.BGTaskSchedulerPermittedIdentifierswith the plugin’s refresh task id.
On iOS flutter_foreground_task creates a headless FlutterEngine and runs the same Dart
entry point in it, so the isolate split above holds on both platforms. AppDelegate.swift
has no tracking code of its own.
Things the code does that you might not expect
Section titled “Things the code does that you might not expect”These come from reading, not from running. Each is worth a test before relying on it.
Every flush is enqueued twice when no drain is running. In _persistAndUpload
(app/lib/tracking/location_service.dart:165-202):
_uploadInFlight ??= () async { try { if (toStore.isNotEmpty) await queue.enqueue(raceId, toStore); // ... } finally { _uploadInFlight = null; }}();if (toStore.isNotEmpty && _uploadInFlight != null) { // A drain was already running: store these now ... queue.enqueue(raceId, toStore).catchError(/* ... */);}When _uploadInFlight is null, the closure runs, enqueues toStore, and its future is
assigned to _uploadInFlight before the if below it runs. So the if sees a non-null
future and enqueues the same fixes again. The comment shows the second branch was meant only
for “a drain was already running”. Because pending sorts by timestamp, the duplicates
usually sit next to each other in one batch and add zero distance on the server, so races
still score correctly, but every fix is stored and uploaded twice. If the second write only
commits after the drain has read the queue, the copies go up in the next drain, behind newer
fixes, which runs into the next point. No test covers LocationTaskHandler.
The server does not dedupe by timestamp. The uploader’s doc comment says “the server
dedupes by timestamp” (app/lib/tracking/upload/position_uploader.dart:105-106). On the backend
Ingest.record/2 inserts a marker per fix and RaceProcess.apply_position/3 adds the distance
from last_position to each new fix (backend/lib/racing/positions/ingest.ex:140-158,
backend/lib/racing/race_process.ex:402-413, backend/lib/racing/race_process.ex:514-530).
If a 200 is lost after the server applied a batch (the 15 s client timeout), the uploader
re-sends it and the server measures from the newest fix back to the oldest re-sent one. At-least-once
delivery is right for the client; the server side would need to skip fixes older than
last_position to make it safe.
A server-side failure on positions is a 422. The last clause of put_position turns any
non-string error into 422 “Failed to record positions”
(backend/lib/footrace_web/controllers/api/race_controller.ex:80-87), and the uploader drops
422 batches as malformed. A transient database error therefore loses that batch instead of
retrying it.
Where to go next
Section titled “Where to go next”- Day 9: GPS, permissions, the foreground-service isolate
- Day 10: The durable upload pipeline
- Race flow shows the tracker next to the race channel.
- Build and release covers the rest of
android/andios/.