Skip to content

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.

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 Map and not a class.
  • Here the second isolate is not spawned by Dart code. flutter_foreground_task starts 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’s main() 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.

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)

  1. _ensureInitialized configures the plugin once: the Android notification channel, and a repeat event every kFlushIntervalMs (2000 ms) with a wake lock (app/lib/tracking/location_service.dart:289-308).
  2. 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.
  3. It starts the service with startLocationCallback as 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.

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.

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.

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).

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.

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 (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.

  • enqueue writes all fixes of a flush in one transaction (app/lib/tracking/upload/fix_queue.dart:54-71).
  • pending returns the oldest un-uploaded fixes for a race, sorted by fix timestamp then insertion key, at most limit (app/lib/tracking/upload/fix_queue.dart:74-87). The sembast Finder with Filter.and and SortOrder reads like an Ecto query.
  • markUploaded sets uploadedAt instead of deleting (app/lib/tracking/upload/fix_queue.dart:97-104); pruneUploaded deletes those older than a day, and runs every time the pipeline opens (app/lib/tracking/location_service.dart:94).
  • clearRace drops 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.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.

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”.

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.

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:

  • NSLocationWhenInUseUsageDescription and NSLocationAlwaysAndWhenInUseUsageDescription (the prompt strings).
  • UIBackgroundModes with location and fetch.
  • BGTaskSchedulerPermittedIdentifiers with 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.