Boot & killed-app behavior
BGeo’s contract is that tracking and upload are native, and Dart is
optional at runtime. Once ready()
has started tracking, the foreground service (Android) or location engine
(iOS) keeps recording, filtering, persisting to SQLite, and uploading with no
Dart isolate required. Your onLocation/onMotionChange/etc. listeners are
UI observers on top of that pipeline, not part of it — see the
Tracking lifecycle concept
for the one-paragraph summary this guide expands into a full matrix.
“Killed” isn’t one state. This guide distinguishes five, because each has a different platform story:
- Backgrounded — the app is merely not in the foreground. Both platforms keep the Dart isolate alive; nothing here is really about killing at all.
- Swiped away / task removed — the user removes the app from the Android recent-apps list. iOS has no equivalent gesture that’s distinct from a force-quit.
- Force-quit (iOS meaning) — the user swipes the app away in iOS’s app-switcher, which is Apple’s explicit signal to stop the process and (on older iOS behavior) suppress most background relaunch triggers.
- OS-killed (memory eviction) — the OS terminates the process itself to reclaim memory, with no user action. The most common real-world case is an overnight eviction of a backgrounded app.
- Device reboot — the whole OS restarts; every process is gone and every
in-memory registration is gone with it, though some platform registrations
(Android’s boot broadcast, iOS’s
locationdstate) persist across the reboot itself.
The matrix
The verified end-state (device-tested, see the sourcing note in each row) for what keeps tracking alive, what relaunches the process, and the expected tracking gap — this is native engine behavior, identical to the same SDK’s React Native binding:
| Scenario | Android | iOS |
|---|---|---|
| App backgrounded | Foreground service keeps running; Dart isolate alive. No gap. | Session engine (useSessionEngine, default on iOS 17+) keeps a live location stream that auto-pauses at the parked keep-alive and auto-resumes on movement — no relaunch needed, Dart isolate alive. No gap. |
| Swiped away (task removed) | onTaskRemoved in the foreground service immediately restarts it (START_STICKY) — tracking continues without ever fully stopping. No gap. | No distinct gesture — the app-switcher swipe is iOS’s force-quit; see the next row. |
| Force-quit (iOS meaning) | N/A — Android has no force-quit concept distinct from task removal, handled above. | The session engine relaunches the process and recreates its session synchronously at launch (recreateSessionStreamOnRelaunch + CLServiceSession, iOS 18+), continuing the authorization grant held before the kill. Device-verified across three force-quit deaths: relaunch in ~1 second, roughly a second of tracking loss. The rolling wake region (armed in both motion states, re-centered on enterMoving) is a secondary relaunch trigger and the only one on the pre-iOS-17 legacy path, which has no session to recreate — that path relaunches on a region exit, roughly 1–2× stationaryRadius of silence. |
| OS-killed (memory eviction) | Foreground service is designed not to be evicted under normal conditions; if the process still dies, onTaskRemoved/service restart semantics apply the same as above. | Same session-recreation-at-relaunch path as force-quit — an OS-initiated termination is not distinguished from a user force-quit by the engine. The overnight-eviction case (backgrounded app, process dies while the phone sleeps) is closed by the same mechanism: the session is recreated synchronously in init, and liveUpdates auto-resume relaunches the app in the background the moment movement resumes, with no foreground visit required. |
| Device reboot | BootReceiver restarts the service on BOOT_COMPLETED/LOCKED_BOOT_COMPLETED when tracking was enabled and startOnBoot is true. With false, nothing survives the reboot (geofences, activity-recognition PendingIntents, the foreground service all die with the process) until the app is next opened. | No boot broadcast exists, so startOnBoot:true is best-effort by design: the wake-region and significant-location-change registrations persist in locationd across the reboot and relaunch the app in the background once movement or a region event triggers them — device-verified relaunch in roughly 1 minute after boot, parked, no movement required, with the wake region confirmed still armed in locationd post-reboot. false is honoured via kern.boottime reboot detection: init compares the persisted boot time against the current one (tolerant of clock drift, not systemUptime, which pauses during device sleep) and drops the persisted enabled flag before any auto-resume path runs, so relaunch finds tracking disabled and does nothing. |
stopOnTerminate & startOnBoot
These two config keys govern the two sides of app-kill behavior — whether tracking survives a kill, and whether it survives a reboot — and both are honoured on both platforms, but through different mechanisms because iOS has no reliable terminate or boot hook.
stopOnTerminate
(default false): whether tracking should stop when the app process is
killed.
- Android tears tracking down directly, synchronously, in the
foreground service’s
onTaskRemoved. - iOS has no terminate hook to run code in, so the flag is honoured at
next relaunch instead:
initdrops the persistedenabledflag before any auto-resume path runs (bothrecreateSessionStreamOnRelaunchand the deferredstartTrackingare gated on it). Dart seesstate.enabled == falseon the nextready()/app open and has to callstart()again — there’s a short window between the actual kill and the next relaunch where, from iOS’s perspective, tracking hasn’t stopped yet (the process is simply gone), but no further fixes are produced because the process isn’t running either way.
startOnBoot (default
false): whether tracking should resume automatically after a reboot, if it
was enabled when the device went down.
- Android’s implementation matches its name literally:
BootReceiverrestarts the service onBOOT_COMPLETED/LOCKED_BOOT_COMPLETEDwhen bothisEnabled()and this flag are true. - iOS has no boot broadcast, so
trueis best-effort: the wake-region and SLC registrations that survive the reboot inlocationdare what relaunch the app, not a dedicated boot handler.falseis still honoured — reboot detection viakern.boottimedrops the persistedenabledflag on the first relaunch after a reboot, so nothing auto-resumes.
Android headless tasks
While the Dart isolate isn’t running (app killed, or not yet launched since
boot), Android can still invoke a small piece of Dart through
registerHeadlessTask():
native code (FlutterHeadlessDispatcher) spins up a background FlutterEngine,
gives your task a short execution window, and tears it back down when the
task’s Future completes.
Your task must be a top-level function or a static method — a closure or
instance method can’t be resolved by PluginUtilities.getCallbackHandle in a
fresh background isolate, and registerHeadlessTask() throws ArgumentError
immediately if you pass one. Register it early — typically right alongside
your ready() call — since the call to registerHeadlessTask() itself can
happen anywhere in your app’s startup path; it’s the task function that has
the top-level constraint, not the call site:
import 'package:bgeo_background_geolocation/bgeo_background_geolocation.dart' hide State;
@pragma('vm:entry-point')Future<void> headlessTask(HeadlessEvent event) async { print('[bgeo headless] ${event.name} ${event.params}');}
Future<void> main() async { await BackgroundGeolocation.registerHeadlessTask(headlessTask); runApp(const MyApp());}Event shape. A headless
HeadlessEvent has a top-level name
plus a params map holding every other payload field:
class HeadlessEvent { final String name; // 'heartbeat' | 'motionchange' | 'geofence' | 'providerchange' // | 'powersavechange' | 'http' | 'connectivitychange' final Map<String, dynamic> params;}Runtime event set. Seven event types reach the headless task:
heartbeat, motionchange, geofence, providerchange,
powersavechange, http, and connectivitychange. location is
deliberately excluded — forwarding every accepted fix headlessly would spin
up a background engine (and a wakelock) per fix while tracking, draining
the battery and risking ANRs/foreground-service-start restrictions. Fixes
taken while the Dart isolate isn’t running are still durably queued and
uploaded by the native HTTP store; they just don’t reach a headless
listener. See the per-event Headless line on the
Events reference for which events carry
which fields headlessly.
Constraints. A headless task runs on a strict execution budget: keep it
fast, do no UI work (there’s no mounted widget tree to update), and prefer
await-able async work — anything long-running risks the OS killing the
task before it completes.
iOS: no headless
iOS has no equivalent of Android’s headless-dispatch mechanism, so
registerHeadlessTask()
retains your task on iOS but never invokes it while the app process isn’t
running — Dart simply does not run for a killed iOS app, headless or
otherwise. This is by design, not a gap to work around: the native engine
already keeps recording, filtering, persisting, and uploading independently
of Dart, so nothing at the OS layer is actually waiting on Dart to run.
The design answer for “I need this to survive a kill” is the same on both platforms — do it natively, not in Dart:
- Native uploads (queue drain, retry, poison-record handling, and
authorizationtoken refresh) all continue with no Dart isolate, on both platforms. See Authorization (JWT refresh). - Attach anything the server needs at ingest time via
urlrequest shaping (params,extras) rather than deriving it in a Dart listener. - Use
logUrlfor native-side diagnostic logging that needs to reach your server even when nothing Dart ever runs.