Skip to content

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.

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

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 own ForegroundService class, foregroundServiceType="location". The comment says not to rename it; the plugin starts it by that class name. Android refuses to start a location-type foreground service without a location grant, which is why LocationNotifier.build asks even in fake-GPS mode (the comment at app/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: UIBackgroundModes with location and fetch.
  • app/ios/Runner/Info.plist:78-81: the plugin’s BGTaskScheduler identifier.

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.

  1. Run the gate tests. From footrace-flutter/:

    Terminal window
    flutter test test/features/permissions/permissions_test.dart

    Then, next to the test at line 39 (“denied asks, while-in-use asks for always”), add one that sets platform.permission = LocationPermission.whileInUse and platform.notification = NotificationPermission.denied (the fake’s fields are at app/test/support/fakes.dart:292-295). Predict the step before you run it; question 4 below has the answer.

  2. 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=true

    Profile tab → GPS diagnostics → Start race tracking. In flutter logs (a second terminal) you should see LocationTaskHandler.onStart (...) fake=true from line 61 and a Flush: 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, change kFlushIntervalMs on line 17 to 5000, 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.

  1. Why does onStart read the fake-GPS flag with getData instead of reading appConfigProvider?
  2. Which direction of traffic goes through IsolateNameServer, and which goes through a method channel?
  3. What is the difference between allowWakeLock: true on line 304 of location_service.dart and keeping the screen on?
  4. In PermissionGate._evaluate, what step does a user who granted “while using” but has notifications off see first, and why that one?
  5. Why does LocationTaskHandler.onReceiveData copy _source into a local before the is check?
Answers
  1. The service runs in a second isolate on a headless engine. Providers, AppConfig and 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 before startService (app/lib/tracking/location_service.dart:329-348).
  2. Service → UI goes through the SendPort registered by name in initCommunicationPort (sendDataToMain). UI → service goes through the plugin’s method channel to native code (sendDataToTask), which forwards it into the service engine’s onReceiveData.
  3. allowWakeLock keeps 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.
  4. needsAlwaysLocation. The location switch returns before notifications are checked, so the order is the enum’s order: services, location, always, then notifications.
  5. Dart does not promote a non-final field after an is check, because it could be reassigned between the check and the use. A final local can be promoted, so the code copies the field into one first.

Day 10: The durable upload pipeline.