Day 9: GPS, permissions, the foreground-service isolate
Today covers the part of the app that CLAUDE.md calls load-bearing: the tracker under
lib/tracking/. A race is only worth something if distance keeps counting while the phone is
locked in a pocket, and on both platforms that needs a foreground service (Android) or a
background location mode (iOS), plus a chain of permission prompts in the right order. In
Flutter it also means a second Dart isolate that cannot see anything the rest of the app
knows.
Before you start
Section titled “Before you start”You need Day 5 for AsyncNotifier,
ref.read and provider overrides, and the Stream material from
Day 1. Day 8
ends with the app navigating to /race/:id; today is what has to be in place before that
screen can track anything. The layer-by-layer reference for this code is
Tracking, and the native side is covered in
Build and release.
One Dart fact to hold onto: an isolate is a thread of Dart execution with its own heap.
Two isolates share no objects at all; they talk by sending messages through ports. That is a
BEAM process, almost exactly: own memory, send to a pid, receive in a loop. Where the
analogy breaks: there is no supervisor, no link, no registry of pids beyond one small
name-to-port table (IsolateNameServer), and a message is copied (or, for some types,
transferred), not shared. The UI of a Flutter app runs on one isolate, the “main” or “UI”
isolate. Everything you have read so far ran there.
1. Two isolates, one port: main.dart and the plugin
Section titled “1. Two isolates, one port: main.dart and the plugin”Open app/lib/main.dart:9-21. Line 14 is the only tracking code in bootstrap:
void main() { WidgetsFlutterBinding.ensureInitialized(); installErrorHandlers(); // Opens the channel the foreground-service isolate uses to send GPS // batches to the UI. Must happen before any tracking starts. FlutterForegroundTask.initCommunicationPort(); runApp( // ... );}What that call does is in the plugin (flutter_foreground_task 8.17.0, in your pub cache
under lib/flutter_foreground_task.dart, around line 240): it creates a ReceivePort, takes
its SendPort, and registers it with IsolateNameServer under the name
'flutter_foreground_task/isolateComPort'. Every message that arrives on that port is fanned
out to the callbacks you registered with addTaskDataCallback. In the other direction,
sendDataToMain looks the port up by that name and calls send.
In Elixir terms: the UI isolate does Process.register(self(), :isolate_com_port) at boot,
and the service isolate does send(:isolate_com_port, batch). If main() forgot to
register, lookupPortByName returns null and the plugin’s sendPort?.send(data) drops every
batch silently. That is why CLAUDE.md insists it runs first.
Where does the second isolate come from? Not from Isolate.spawn. The plugin’s native side
starts a whole second Flutter engine without a UI (Android: ForegroundTask.kt creates a
FlutterEngine(context) inside a real Service; iOS: ForegroundTask.swift creates a
headless FlutterEngine). That engine runs one Dart function you name, the way a Service
subclass gets onStartCommand. Messages UI → service do not use the port; sendDataToTask
goes through a method channel to native code, which forwards it into the other engine.
sequenceDiagram
participant UI as UI isolate (main engine)
participant N as Native (Service / iOS task)
participant S as Service isolate (headless engine)
UI->>UI: initCommunicationPort() registers SendPort by name
UI->>N: saveData(...) then startService(callback)
N->>S: run startLocationCallback()
S->>S: onStart: read saved data, start GPS
loop every 2 s
S-->>UI: sendDataToMain({type: batch, fixes: [...]})
end
UI->>N: sendDataToTask({type: sim, cmd: pause})
N->>S: onReceiveData(...)
2. Inside the service isolate: LocationTaskHandler
Section titled “2. Inside the service isolate: LocationTaskHandler”Open app/lib/tracking/location_service.dart. The file has two halves: the handler that runs
in the service isolate (lines 24-221) and LocationService, the UI-side facade (lines
223-387). Read the top half first.
The entry point. app/lib/tracking/location_service.dart:26-29:
@pragma('vm:entry-point')void startLocationCallback() { FlutterForegroundTask.setTaskHandler(LocationTaskHandler());}It must be a top-level function: the new engine has no object graph to find a method on, only
a symbol. The @pragma('vm:entry-point') annotation tells the AOT compiler not to tree-shake
it, since nothing in Dart calls it; native code does. Swift’s @objc on a selector that only
the runtime calls is a fair comparison.
onStart (app/lib/tracking/location_service.dart:53-73). TaskHandler is the plugin’s
lifecycle class: onStart, onRepeatEvent, onReceiveData, onDestroy. Think Service’s
onCreate/onStartCommand/onDestroy, plus a timer callback. The first thing onStart
does is read two values with FlutterForegroundTask.getData (lines 55-59): fake GPS on or off,
and the simulator’s pace. This is the rule from CLAUDE.md in action: the service isolate
cannot read Riverpod, AppConfig or the keychain, because none of those objects exist on its
heap. Lines 62-65 pick a LocationSource, line 67 opens the upload pipeline (Day 10), line 68
subscribes, line 72 starts the source.
LocationSource (app/lib/tracking/location_source.dart:11-15) is a three-member
abstract class: a Stream<GpsFix>, start(), stop(). Dart has no separate interface
keyword here; any class can be implements-ed, which is what RealLocationSource does at
line 19. Its settings (lines 20-23) are best accuracy and distanceFilter: 0, and the comment
explains why: at race pace a 10 m filter drops most fixes. Line 25 is a
StreamController.broadcast(), the Dart way to own a hot stream you push into, much like a
Kotlin MutableSharedFlow or an RxSwift PublishSubject.
Compare with what you have written natively:
| iOS (UIKit era) | Android | Here | |
|---|---|---|---|
| Provider | CLLocationManager |
FusedLocationProviderClient |
Geolocator.getPositionStream (wraps both) |
| Accuracy | kCLLocationAccuracyBest |
PRIORITY_HIGH_ACCURACY |
LocationAccuracy.best |
| Filter | kCLDistanceFilterNone |
setMinUpdateDistanceMeters(0) |
distanceFilter: 0 |
| Delivery | delegate callback | LocationCallback |
Stream<Position> mapped to GpsFix |
| Keep alive | allowsBackgroundLocationUpdates + UIBackgroundModes |
foreground service, type location |
flutter_foreground_task + both of those |
_onFix (app/lib/tracking/location_service.dart:110-129) records the gap since the
last fix, appends the fix to _pendingEnqueue (for the upload queue) and a plain map to
_buffer (for the UI). Why maps? Messages between isolates are copied, and the plugin’s port
traffic sticks to primitives, lists and maps. GpsFix.fromMap on the other side
(app/lib/tracking/gps_fix.dart:31-39) rebuilds the object; note its keys are lat/lng,
not the latitude/longitude of toJson (lines 41-48). Two serialisations of one class,
for two different wires.
onRepeatEvent (app/lib/tracking/location_service.dart:131-161) fires every
kFlushIntervalMs (2000 ms, line 17; set as the plugin’s repeat interval on line 303). It is
not the sample rate. It copies and clears the buffer, sends one {'type': 'batch', ...} map
to the UI (line 150), hands the fixes to the upload pipeline (line 151), and rewrites the
Android notification text with pace and fix count (lines 157-160). That notification is the
user-visible proof the service is alive.
onReceiveData (lines 205-209) is the inbox for UI → service messages. Only the
simulator listens: if (source is SimulatedLocationSource) source.applyCommand(data);. The
is check promotes source to the subtype inside the if, the way Kotlin smart-casts. The
local final source = _source; on the line above is there because Dart will not promote a
mutable field like _source (line 38): another call could reassign it between the check and
the use.
3. The UI side: starting the service and hearing it
Section titled “3. The UI side: starting the service and hearing it”Now the bottom half of location_service.dart. LocationService is a singleton
(app/lib/tracking/location_service.dart:223-228): a static final instance, a private named
constructor _internal(), and a factory constructor that returns the instance. A factory
constructor is a constructor allowed to return something other than a fresh object, which
makes LocationService() read like construction while handing back the shared one.
startBackgroundTracking (app/lib/tracking/location_service.dart:318-362) is the
start payload:
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} else { await _clearUploadTarget();}final result = await FlutterForegroundTask.startService( // ... callback: startLocationCallback,);saveData is SharedPreferences under the hood (the plugin’s getData/saveData call
SharedPreferences.getInstance() and reload()). It is the mailbox both engines can reach,
because it lives on disk rather than in either heap. Think of it as the args you would put in
an Intent extra before startForegroundService, except they are written to prefs first and
read back by the service in onStart. Even the queue path is computed here, on the UI
isolate, because path_provider is a plugin and the service handler does not call it.
stopBackgroundTracking (lines 364-370) clears the same keys on the way out, with a comment
worth reading: the token must not outlive the race in the plugin’s plain storage.
_ensureInitialized (lines 289-308) configures the plugin once: the Android notification
channel, and ForegroundTaskOptions with repeat(kFlushIntervalMs) and
allowWakeLock: true (line 304). That wake lock is the CPU one (Android’s
PARTIAL_WAKE_LOCK): the screen can go off, the CPU keeps running the isolate. Keeping the
screen on is a different library, and a Day 11 topic.
Who calls this? During a race, ForegroundRaceTracker in app/lib/tracking/race_tracker.dart.
The abstract RaceTracker (lines 10-24) is what RaceNotifier depends on, so tests can fake
it. The real one (lines 26-68) registers _onData with addTaskDataCallback (lines 47-50),
asks for permissions (line 51), then starts the service with the race id, base URL and token
(lines 52-57). _onData (lines 36-38) pushes each map into a broadcast
StreamController, turning the plugin’s callback list into a Stream<Map> that Riverpod code
can listen to and cancel.
There is a second consumer, LocationNotifier in app/lib/tracking/location_provider.dart:18-80,
which keeps a TrackingState (app/lib/tracking/gps_fix.dart:55-80) with the latest
foreground and background fix. A search for locationProvider finds it read only in
lib/dev/ (the GPS diagnostics screen), so treat it as the proof-of-concept path that is
still useful for checking a device, not part of the race. Its build registers the same kind
of callback (line 26) and removes it in ref.onDispose (lines 27-30); its _onServiceData
(lines 73-79) ignores anything that is not type == 'batch'.
4. Permissions and native config
Section titled “4. Permissions and native config”The order the platforms make you ask in is encoded as an enum,
app/lib/tracking/permission_gate.dart:7-28: location services on, location granted,
upgraded to “always”, not permanently denied, notifications (Android 13+), and an optional
battery-optimisation exemption. PermissionState.canTrack (lines 43-45) treats the battery
step as non-blocking.
PermissionGate (lines 108-194) is an AsyncNotifier. _evaluate (lines 115-156) is a
straight-line decision: check, return the first missing step. The switch on
LocationPermission at lines 120-139 covers every enum case, and Dart 3 checks that
exhaustiveness at compile time, as Kotlin’s when does on an enum. requestCurrentStep
(lines 163-187) is the action side: one platform call per step, then re-evaluate in
finally. Note there is no break between cases: Dart 3 switch statements do not fall
through, and two labels stacked with no body (lines 171-172) share the next body.
The platform calls sit behind PermissionPlatform (lines 50-63), provided by
permissionPlatformProvider (lines 102-103). That is the seam the tests use:
test/features/permissions/permissions_test.dart overrides it with a
FakePermissionPlatform and walks every step without a device.
The UI is PermissionsSheet in app/lib/features/permissions/permissions_screen.dart.
ensureGranted (lines 24-29) is what callers use: it returns at once if canTrack, else shows
the sheet. It is called before queueing (app/lib/features/race_tab/race_tab.dart:143) and
before joining from the lobby (app/lib/features/lobby/lobby_screen.dart:64), so prompts
never land mid-countdown. The sheet mixes in WidgetsBindingObserver (lines 35-56) and
re-evaluates on AppLifecycleState.resumed: coming back from the Settings app is the only
signal you get, the same as listening for UIApplicationDidBecomeActiveNotification or
overriding onResume. The copy per step is a switch expression that returns a small
_Copy value class (lines 123-172).
PermissionBanner (lines 191-224) shows on the race screen when canTrack turns false. As far
as I can find, nothing re-runs _evaluate while the race screen is up (the only refresh()
caller is the sheet’s lifecycle hook at line 54), so the banner reflects the last evaluation,
not a live check. Could not verify whether that was intended.
Finally, the native declarations, which the Dart code cannot work without:
app/android/app/src/main/AndroidManifest.xml:3-9: fine and coarse location,ACCESS_BACKGROUND_LOCATION,FOREGROUND_SERVICE,FOREGROUND_SERVICE_LOCATION,POST_NOTIFICATIONS,WAKE_LOCK.app/android/app/src/main/AndroidManifest.xml:37-40: the plugin’s ownForegroundServiceclass,foregroundServiceType="location". The comment says not to rename it; the plugin starts it by that class name. Android refuses to start alocation-type foreground service without a location grant, which is whyLocationNotifier.buildasks even in fake-GPS mode (the comment atapp/lib/tracking/location_provider.dart:31-33).app/ios/Runner/Info.plist:69-72: the two usage strings.app/ios/Runner/Info.plist:73-77:UIBackgroundModeswithlocationandfetch.app/ios/Runner/Info.plist:78-81: the plugin’sBGTaskScheduleridentifier.
The old two-step dance is still here in LocationService.requestLocationPermissions
(app/lib/tracking/location_service.dart:232-251): request, and if the answer is
“while in use”, request again, which on Android 11+ opens Settings and on iOS shows the
upgrade prompt. ForegroundRaceTracker.start calls it, and it throws on a denial, which
RaceNotifier turns into a trackingError you will see on Day 11.
Try it
Section titled “Try it”-
Run the gate tests. From
footrace-flutter/:Terminal window flutter test test/features/permissions/permissions_test.dartThen, next to the test at line 39 (“denied asks, while-in-use asks for always”), add one that sets
platform.permission = LocationPermission.whileInUseandplatform.notification = NotificationPermission.denied(the fake’s fields are atapp/test/support/fakes.dart:292-295). Predict the step before you run it; question 4 below has the answer. -
Watch the two isolates talk, on an emulator. Run with simulated GPS so no real fix is needed:
Terminal window flutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 \--dart-define=DEV_TOOLS=true --dart-define=FAKE_GPS=trueProfile tab → GPS diagnostics → Start race tracking. In
flutter logs(a second terminal) you should seeLocationTaskHandler.onStart (...) fake=truefrom line 61 and aFlush:line every two seconds from lines 148-149, each with about two fixes (the simulator ticks once a second). Pull down the notification shade and watch the text from lines 157-160 change. Then stop tracking, changekFlushIntervalMson line 17 to5000, and stop and re-run the app (the service engine is not hot-reloaded with the UI). The flushes should now carry about five fixes each. Put it back.
Check yourself
Section titled “Check yourself”- Why does
onStartread the fake-GPS flag withgetDatainstead of readingappConfigProvider? - Which direction of traffic goes through
IsolateNameServer, and which goes through a method channel? - What is the difference between
allowWakeLock: trueon line 304 oflocation_service.dartand keeping the screen on? - In
PermissionGate._evaluate, what step does a user who granted “while using” but has notifications off see first, and why that one? - Why does
LocationTaskHandler.onReceiveDatacopy_sourceinto a local before theischeck?
Answers
- The service runs in a second isolate on a headless engine. Providers,
AppConfigand the keychain live on the UI isolate’s heap, which the service cannot see. Shared preferences on disk are the one store both can reach, so the UI writes the start payload there beforestartService(app/lib/tracking/location_service.dart:329-348). - Service → UI goes through the
SendPortregistered by name ininitCommunicationPort(sendDataToMain). UI → service goes through the plugin’s method channel to native code (sendDataToTask), which forwards it into the service engine’sonReceiveData. allowWakeLockkeeps the CPU awake so the isolate keeps running with the screen off. Keeping the screen lit is a separate concern, handled on the race screen by another package.needsAlwaysLocation. The locationswitchreturns before notifications are checked, so the order is the enum’s order: services, location, always, then notifications.- Dart does not promote a non-final field after an
ischeck, because it could be reassigned between the check and the use. Afinallocal can be promoted, so the code copies the field into one first.