ex_background_geolocation
Battery-conscious background location tracking, motion detection, and geofencing for Flutter - Android and iOS. Keeps tracking when the app is backgrounded, swiped away, or the device reboots, and delivers fixes to your server with an offline-durable queue.
Built by Exline Labs as a self-hosted, in-house alternative to commercial background-geolocation plugins.
Features
- ๐ Continuous background tracking - foreground service (Android) / background location (iOS).
- ๐ Motion intelligence - stops GPS when the device is stationary and resumes automatically when it moves, using activity recognition + a stationary geofence.
- โป๏ธ Survives app-kill & reboot - resumes tracking after the app is terminated or the phone restarts.
- ๐๏ธ Durable upload queue - every fix is stored in SQLite and POSTed to your endpoint with automatic retry; nothing is lost offline.
- ๐ฐ๏ธ Geofencing - monitor circular regions with ENTER/EXIT events that fire even when the app is closed.
- ๐ Self-healing uploads - failed uploads retry on their own (when the network returns and on a backoff timer), even if the device is parked.
- ๐ Native odometer - running distance counted natively, correct while the app is closed, survives restarts and reboots.
- ๐ด Phone-location awareness - tell when the phone's Location switch is off
(
isLocationServicesEnabled(),onProviderChange). - ๐ Trip state - persistent native counters (distance, points stored/uploaded,
queue depth) plus a full-state snapshot any host app can consume via an Android
broadcast, an iOS notification or
onState- even with the app closed. - ๐ Rich events -
onLocation,onMotionChange,onActivityChange,onGeofence,onHttp,onProviderChange.
Platform support
| Feature | Android | iOS |
|---|---|---|
| Foreground / background tracking | โ | โ |
| Persistent SQLite queue + HTTP upload | โ | โ |
| Survive app-kill / termination | โ | โ ยน |
| Survive reboot | โ | โ ยน |
| Motion state machine (stop GPS when parked) | โ | โ |
| Geofencing (ENTER/EXIT) | โ | โ ยฒ |
| Upload retry on network return + backoff | โ | โ ยณ |
Native odometer + resetOdometer() |
โ | โ |
Phone Location-switch check + onProviderChange |
โ | โ โด |
Trip counters + state snapshot (broadcast / notification / onState) |
โ | โ โต |
ยน iOS relaunches the app on movement via region monitoring / significant-location
changes (coarse, OS-timed) - see Platform notes.
ยฒ iOS monitors a maximum of 20 regions total.
ยณ iOS retries only while the process is alive - a suspended, stationary app drains
its queue on the next relaunch, movement or network change.
โด iOS cannot deep-link to the Location Services switch (openLocationSettings()
opens the app's settings page) and onProviderChange is best-effort there.
โต iOS delivers snapshots only while the app has background execution. A terminated
app is relaunched in the background by a region exit / significant-location change
(see iOS: resume after termination), and
snapshots resume then.
Installation
dependencies:
ex_background_geolocation: ^1.0.0
Minimum: Android minSdk 24 ยท iOS 15.0 ยท Flutter 3.44 (Dart 3.12).
iOS setup
Add to ios/Runner/Info.plist:
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to track trips.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We track your location in the background to record trips.</string>
<key>NSMotionUsageDescription</key>
<string>Used to detect when you're moving to save battery.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
Only the location background mode is needed; the plugin does not use background
fetch or background processing tasks, so don't declare those for it.
Background tracking requires the user to grant "Always" location permission.
iOS: resume after termination (host step)
When iOS relaunches a terminated app in the background for a location event
(leaving the stationary region, or a significant-location change), no UI scene
connects - so with the UIScene lifecycle no Flutter engine is created and plugins are
never registered. Apple requires the location manager and its delegate to exist by
the end of application(_:didFinishLaunchingWithOptions:), otherwise the event is
dropped and tracking never re-arms. Add one line to your AppDelegate:
import ex_background_geolocation
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Before anything else that can be slow; before `return super...`.
ExBackgroundGeolocationPlugin.resumeIfNeeded(launchOptions: launchOptions)
// ...your other setup...
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
It creates the plugin's native location core (one per process; the Flutter plugin
attaches to the same instance when an engine exists) and, if tracking was on,
re-arms it: location updates, significant-location changes and the stationary
region. Uploads, the odometer, trip counters and the ExBackgroundGeolocationState
notification all work without an engine. It is safe to call when tracking is off.
Without this line the plugin still works whenever the app is running, but a
terminated app will not resume tracking.
On iOS 17+ the plugin holds a CLBackgroundActivitySession while tracking is on;
iOS only lets a background-relaunched app keep receiving location if one was active
when the app was terminated, and it keeps tracking responsive after stops. While
the session is held iOS shows the location indicator in the status bar or Dynamic
Island; if your app shows its own Live Activity, iOS shows that instead.
Android setup
Permissions and the foreground-service declaration are merged automatically from
the plugin manifest. No manual changes required (this includes
ACCESS_NETWORK_STATE, a normal permission with no prompt, used to retry uploads
when the network returns). Background tracking and boot-restart require the user
to grant "Allow all the time" location access.
Permissions & the "Always" requirement
Background tracking needs "Always" (iOS) / "Allow all the time" (Android) location access. Neither OS lets an app request that in a single prompt โ this is an Apple/Google restriction, not a plugin limitation:
- The first system prompt only offers While Using / Once / Deny.
- "Always" is granted through a second step.
What the plugin does when you call requestPermission():
- Android โ runs the guided flow: foreground prompt โ an explainer
dialog (customise via
backgroundPermissionRationale) โ routes the user to the "Allow all the time" Settings screen โ then, on Android 10+, the optional "Physical activity" prompt (see Activity recognition). Steps already satisfied are skipped. - iOS โ requests When-In-Use, then triggers the Always request; iOS shows its own "Keep Allowing in Background?" upgrade prompt automatically once the app first uses background location. Otherwise the user enables it in Settings.
To read the current status without prompting, use getPermissionStatus()
(returns "always", "whenInUse", "denied", or "notDetermined"):
final current = await ExBackgroundGeolocation.getPermissionStatus();
if (current == 'always') {
await ExBackgroundGeolocation.start(); // already granted, no prompt shown
}
requestPermission() returns "always", "whenInUse", or "denied". Your
app should check this and, if it isn't "always", explain why background
access is needed and send the user to Settings with the built-in helper:
final status = await ExBackgroundGeolocation.requestPermission();
if (status != 'always') {
await ExBackgroundGeolocation.openSettings(); // opens this app's Settings page
}
Activity recognition (Android 10+, optional)
Motion detection uses Android's "Physical activity" permission
(ACTIVITY_RECOGNITION). It is optional: without it tracking still works, but
motion detection falls back to location only: the plugin goes stationary after
stopTimeout with no movement (a stop timer that every fix farther than
max(distanceFilter, stationaryRadius) + its accuracy restarts), and resumes through
the stationary geofence or the low-power location backstop rather than an activity.
It works, but reacts more slowly and can mistake a long stop (e.g. a red light longer
than stopTimeout) for parking.
requestPermission() asks for it as its last step, whenever it is missing -
even if location was already granted earlier - and only once: if the user
declines, later calls skip it so they are never nagged. A missing or declined
activity permission never changes the location status requestPermission()
returns. When it is granted while tracking is running, motion detection starts
immediately - no restart and no second start().
An app whose location is already "always" has no reason to call
requestPermission() again, so two methods handle this one permission on its own:
getActivityRecognitionStatus()โ'granted'|'denied'|'notApplicable'(never prompts;notApplicableon Android 9 and below and on iOS).requestActivityRecognition()โtrueif granted (or nothing needs granting on this platform). Asks for ONLY this permission; shows the prompt even after an earlier decline, unless Android itself has stopped offering it ("Don't ask again").
if (await ExBackgroundGeolocation.getActivityRecognitionStatus() == 'denied') {
final ok = await ExBackgroundGeolocation.requestActivityRecognition();
if (!ok) showHint('Without Physical activity, GPS stays on while parked.');
}
On iOS motion detection uses Core Motion, which has its own authorization handled separately - these two methods are safe no-ops there.
Quick start
import 'package:ex_background_geolocation/ex_background_geolocation.dart';
// 1. Request permission (foreground โ then "Always").
await ExBackgroundGeolocation.requestPermission();
// 2. Listen for events.
ExBackgroundGeolocation.onLocation((location) {
print('Location: ${location.coords.latitude}, ${location.coords.longitude}');
});
ExBackgroundGeolocation.onHttp((event) {
print('Upload: ${event.success} (${event.status})');
});
// 3. Configure and start.
await ExBackgroundGeolocation.ready(Config(
desiredAccuracy: LocationAccuracy.high,
distanceFilter: 10,
stopTimeout: 60,
url: 'https://your-server.com/locations',
autoSync: true,
headers: {'Authorization': 'Bearer <token>'},
stopOnTerminate: false,
startOnBoot: true,
));
await ExBackgroundGeolocation.start();
Configuration (Config)
| Option | Type | Default | Description |
|---|---|---|---|
desiredAccuracy |
int |
high |
LocationAccuracy.high/medium/low/veryLow. |
distanceFilter |
double |
10 |
Minimum metres moved before a new fix is reported. |
stationaryRadius |
double |
25 |
Radius of the stationary geofence, floored to 150 m on both platforms (geofencing is unreliable below ~100-150 m). Also the movement threshold of the stop timer. |
stopTimeout |
int |
60 |
Seconds with no movement before tracking goes stationary (GPS off). The timer starts when tracking starts or resumes, and any fix that moved more than max(distanceFilter, stationaryRadius) + its accuracy restarts it. |
stationaryGraceTime |
int |
300 |
iOS only. Seconds after a stop that a low-accuracy (cell/Wi-Fi) location feed keeps the app awake, so moving off again resumes tracking at once. After it only the stationary region resumes tracking (iOS notices that 200-500 m away). Uses a little battery for this window per stop; 0 turns it off. |
stopOnTerminate |
bool |
true |
true stops tracking when the app is swiped away. Set false to keep tracking (Android: the foreground service keeps running; iOS: the stationary region is placed at the last fix so iOS relaunches the app when it moves). |
startOnBoot |
bool |
false |
Resume tracking after device reboot. |
url |
String? |
null |
Endpoint that queued locations are POSTed to. |
autoSync |
bool |
true |
Upload immediately as fixes are recorded. |
uploadInterval |
int |
0 |
Minimum seconds between stored/uploaded points (60 = at most one per minute). 0 stores every fix. Affects only the upload queue - onLocation still gets every fix. |
retryMaxInterval |
int |
300 |
Failed uploads are retried without waiting for a new fix: as soon as the network returns, and on a 15 s / 30 s / 60 s... backoff capped at this many seconds. 0 disables. Records the server rejects as invalid (HTTP 400/413/414/415/422) are dropped after 3 attempts; every other failure is kept and retried. |
uploadBackoff |
bool |
true |
While a retry is waiting, new locations are only queued, which saves battery and data when the server is down. false also tries again with every new location: points arrive sooner once the server is back, but it uses more battery. |
emitStateEvents |
bool |
true |
false = no trip-state snapshots on any channel (Android broadcast, iOS notification, Dart onState). getTripStats() still works. |
stateEventInterval |
int |
0 |
Minimum seconds between "fix" snapshots (0 = every fix). Only "fix" is throttled. |
headers |
Map |
{} |
Extra HTTP headers on each upload. |
params |
Map |
{} |
Extra top-level fields merged into each JSON body. |
locationFields |
List<String>? |
null |
Whitelist of fields to include in the POST body. null sends everything. Affects only the upload - onLocation still gets all fields. |
notificationTitle / notificationText |
String |
- | Android foreground-service notification. |
backgroundPermissionRationale |
Map? |
null |
Android "Allow all the time" dialog copy. |
licenseKey |
String? |
null |
Required for release builds - see Licensing. |
Shaping the upload body
By default the full body is POSTed. Use locationFields to send only what your
endpoint needs (coord fields stay nested under coords):
Config(
url: 'https://your-server.com/v2/location',
locationFields: ['latitude', 'longitude', 'timestamp'],
);
{ "coords": { "latitude": 8.58, "longitude": 81.22 }, "timestamp": "2026-..." }
Valid names: latitude, longitude, accuracy, altitude, speed, heading
(nested under coords), plus timestamp, utcOffsetMinutes, is_moving, odometer,
event.
Anything in params is always appended.
Location payload
Each location (and each uploaded record) looks like:
{
"coords": { "latitude": 8.58, "longitude": 81.22, "accuracy": 5.0,
"altitude": 12.0, "speed": 9.8, "heading": 90.0 },
"timestamp": "2026-10-08T05:11:43.598Z",
"utcOffsetMinutes": 330,
"is_moving": true,
"odometer": 1523.4,
"event": "motionchange"
}
timestamp is ISO-8601 UTC. utcOffsetMinutes is the phone's offset from UTC in
whole minutes at the moment of that fix (60 = UTC+1, -300 = UTC-5). It is
computed per point from the fix time, not once at startup, so it stays correct
when the driver crosses time zones, when daylight saving changes, and for points
queued offline and uploaded later. odometer is in metres (see
Odometer). event is present only on events such as motionchange.
Throttling uploads (uploadInterval)
If your server keeps one point per minute, set uploadInterval: 60. Fixes that
arrive sooner than that after the last stored point are skipped for the upload
queue only - onLocation still receives every fix. Spacing is measured from the
fix time, so on Android (fixes every ~5 s) the actual gap is the interval rounded
up to the next fix. Nothing is sent while the device is stationary (GPS is off).
Upload retry
Every fix is stored in SQLite and POSTed oldest-first, one record per request; a record is deleted only once the server answers 2xx. If an upload fails the queue is kept and retried without waiting for a new location:
- When the network returns - drains immediately (event-driven; no polling while offline).
- Backoff timer - while the network is up but the server fails: 15 s, 30 s,
60 s... capped at
retryMaxInterval(default 300 s), reset after a successful drain. - The next fix and a manual
sync()still trigger an attempt.
A record the server rejects as invalid (HTTP 400, 413, 414, 415, 422) would
otherwise block everything behind it, so it is tried 3 times and then dropped.
Every other failure - network errors, 5xx, 429, and also 401/403/404 - is kept and
retried, so an expired token or an outage never loses data. retryMaxInterval: 0
turns retries off. Uploads are serialised, so the automatic drain and a manual
sync() never POST the same record twice. onHttp fires for every attempt.
Retries run while the process is alive: on Android that is whenever tracking is running (if tracking is stopped with points queued, they upload on the next start); on iOS see footnote ยณ above.
Odometer
location.odometer is the distance in metres travelled since the last
resetOdometer(). It is counted natively, so it keeps accumulating while the app
is backgrounded, closed or running a headless task, and queued/uploaded points
carry the right value. It is saved after every movement, so it survives a service
restart, app kill and reboot. Stopping and starting tracking does not clear
it - call resetOdometer() at the start of each job/trip:
ExBackgroundGeolocation.onLocation((l) => print('${l.odometer} m'));
await ExBackgroundGeolocation.resetOdometer(); // next fix starts a fresh count at 0
- The first fix after a start, restart or reset only sets the starting point (after a restart the total carries on from its saved value; after a reset it reads 0).
- GPS noise is ignored: fixes with accuracy worse than 100 m are skipped, and a segment implying more than 70 m/s is treated as a GPS jump and skipped.
- Distance moved while tracking was stopped is not counted.
- It is a straight-line sum between fixes, so it slightly under-reads on twisty
roads; lower
distanceFilterfor more precision.
Trip state
Persistent trip counters plus a state snapshot that any host app can consume - natively or from Dart - even while the app is backgrounded or closed. The plugin owns the numbers and publishes the snapshot; what a host does with it is up to the host. A custom notification is one example, not a feature of the plugin; others are an iOS Live Activity, a home-screen widget, a watch companion, analytics or a diagnostics screen. The plugin neither knows nor cares which.
Counters
Counted natively and persisted (SharedPreferences / UserDefaults), so they keep accumulating across service restarts, app kills and reboots. Dart never owns them.
| Counter | Meaning |
|---|---|
odometerMeters |
Distance in metres since the last reset (see Odometer). |
pointsStored |
+1 each time a point is inserted into the upload queue (after the uploadInterval throttle). |
pointsUploaded |
+ the number of queued records deleted because the server answered 2xx. Records dropped as invalid (HTTP 400/413/414/415/422 after 3 tries) are not counted. |
pendingCount |
Not a counter: the queue depth, always read live, never cached. |
Only resetTrip() zeroes them; resetOdometer() zeroes the odometer alone.
final stats = await ExBackgroundGeolocation.getTripStats(); // totals + live pendingCount, e.g. on launch
await ExBackgroundGeolocation.resetTrip(); // start of each journey/job: odometer + both counters -> 0
resetTrip() also clears the odometer baseline, so the next fix only sets a
starting point.
getTripStats() also returns isMoving - the plugin's current moving/stationary
state, the same value every snapshot carries (true until a state has been
recorded). Events such as onMotionChange and onState are dropped while Dart is not
attached (for example the app was in the background), so call getTripStats() when
the app returns to the foreground to re-sync a UI that would otherwise stay stuck on
"moving".
final stats = await ExBackgroundGeolocation.getTripStats();
setMovingUi(stats.isMoving); // correct even if the stationary event was missed
The state snapshot
One immutable snapshot, with the same field names on every channel. Every snapshot is the full current state, never a delta, so a consumer can simply render it. Optional fields are absent until they have a value.
| Field | Type | Description |
|---|---|---|
event |
String | "fix" | "upload" | "motion" | "start" | "stop" |
odometerMeters |
double | Distance in metres. |
pointsStored |
int | Points queued for upload so far. |
pointsUploaded |
int | Records the server accepted (2xx). |
pendingCount |
int | Records waiting in the queue (live). |
isMoving |
bool | The plugin's moving/stationary state. |
tracking |
bool | Whether the service is currently tracking. |
fixTimeMs |
long | Epoch ms of the latest fix (0 if none yet). |
latitude, longitude |
double | Latest fix (omitted if none). |
accuracyMeters |
double | Latest fix accuracy (omitted if unknown). |
speedMps |
double | Latest fix speed in m/s (omitted if unknown). |
lastUploadOk |
bool | Outcome of the most recent upload attempt (omitted until one has happened). |
lastUploadStatus |
int | Its HTTP status; 0 for a network failure. |
lastUploadAtMs |
long | Epoch ms of that attempt. |
When it is emitted
"fix"- after every handled fix, after the odometer update and the queue insert, so the counters are already current. Also for fixes theuploadIntervalthrottle skipped (odometer and position changed;pointsStoredsimply didn't)."upload"- after every upload attempt: automatic uploads, retries and a manualsync()(one snapshot per upload pass, which may POST several queued records), sopendingCountandpointsUploadedare fresh andpendingCountfalls to 0 while a device is parked."motion"- on every moving/stationary change."start"/"stop"- when tracking starts or stops.
Delivery channels
Android - native broadcast. An explicit broadcast scoped to your own app's
package (intent.setPackage(context.packageName)), sent from the native service
whether or not Dart or an Activity exists. It contains coordinates, so no other app
can receive it.
- Action:
com.exlinelabs.ex_background_geolocation.action.STATE(public constantTripState.ACTION_STATE). - Extras: flat
Long/Int/Double/Boolean/Stringvalues named exactly as the fields above; omitted optional fields are absent (usecontainsKey).
Handle it with a manifest-declared receiver (exported=false) - it works with no
Flutter engine running:
<receiver android:name=".TripReceiver" android:exported="false">
<intent-filter>
<action android:name="com.exlinelabs.ex_background_geolocation.action.STATE" />
</intent-filter>
</receiver>
class TripReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val e = intent.extras ?: return
val event = e.getString("event") // "fix" | "upload" | ...
val km = e.getDouble("odometerMeters") / 1000
val pending = e.getInt("pendingCount")
val lat = if (e.containsKey("latitude")) e.getDouble("latitude") else null
// What you do with it is up to your app: a notification, a widget, analytics...
}
}
iOS - NotificationCenter. The plugin posts
NotificationCenter.default.post(name: ExBackgroundGeolocationPlugin.stateNotification, object: nil, userInfo: [...])
with the same keys (name "ExBackgroundGeolocationState", public static constant
stateNotification). It may be posted from a background queue, so an observer must
dispatch itself. Register the observer early, in
application(_:didFinishLaunchingWithOptions:), so it also receives snapshots when
iOS relaunches the app in the background for a location event.
import ex_background_geolocation
// AppDelegate.application(_:didFinishLaunchingWithOptions:)
NotificationCenter.default.addObserver(
forName: ExBackgroundGeolocationPlugin.stateNotification, object: nil, queue: .main
) { note in
guard let s = note.userInfo else { return }
let meters = s["odometerMeters"] as? Double ?? 0
let pending = s["pendingCount"] as? Int ?? 0
// What you do with it is up to your app: a Live Activity, a widget, analytics...
}
Dart - onState. Returns a StreamSubscription<TripState>, delivered while the
Dart side is attached (same fields; optional ones are nullable):
ExBackgroundGeolocation.onState((s) => print('${s.event}: ${s.odometerMeters} m, '
'${s.pointsUploaded}/${s.pointsStored} uploaded, ${s.pendingCount} pending'));
Config
| Option | Default | Description |
|---|---|---|
emitStateEvents |
true |
false = no snapshots on any channel (getTripStats() still works). |
stateEventInterval |
0 |
Minimum seconds between "fix" snapshots (0 = every fix). "upload", "motion", "start" and "stop" are never throttled. |
Caveats. On iOS snapshots are delivered only while the app has background execution; a terminated app delivers them again once iOS relaunches it for a location event (needs the host step). On Android, OEM battery limits can still stop the service (see the Android OEM note under Platform notes).
Phone location switch vs app permission
getPermissionStatus() reports this app's permission. An app can hold
"always" while the phone's Location switch is off - then no fixes arrive.
if (!await ExBackgroundGeolocation.isLocationServicesEnabled()) {
await ExBackgroundGeolocation.openLocationSettings();
}
ExBackgroundGeolocation.onProviderChange((enabled) => print('location on: $enabled'));
onProviderChange is delivered while the app is running; call
isLocationServicesEnabled() on launch/resume for the current state.
API
Lifecycle - ready(config), start(), stop(), isTracking(),
changePace(bool), getCurrentPosition(), requestPermission(),
getPermissionStatus(), openSettings(), isLocationServicesEnabled(),
openLocationSettings(), resetOdometer(), resetTrip(), getTripStats(),
getActivityRecognitionStatus(), requestActivityRecognition().
getPermissionStatus() reports this app's permission; isLocationServicesEnabled()
reports whether the phone's Location switch is on (an app can hold "always"
while the phone's location is off, in which case no fixes arrive).
location.odometer is the distance in metres counted natively since the last
resetOdometer(); it keeps counting while the app is closed and survives
restarts and reboots.
Queue - getCount(), sync(), destroyLocations().
Geofencing - addGeofence(Geofence), addGeofences(list),
removeGeofence(id), removeGeofences(), getGeofences().
Events - onLocation, onMotionChange, onActivityChange, onGeofence,
onHttp, onProviderChange (phone Location switch on/off), onState (trip-state snapshots).
Headless task (Android) - registerHeadlessTask(callback).
Deferred events - addDeferredEvent(String), getDeferredEvents(),
clearDeferredEvents().
Headless task (Android)
Runs a Dart function for events that arrive while no app UI is running - the app
was swiped away or killed while tracking continues (stopOnTerminate: false). The
plugin starts a background Dart isolate and calls your function with a
HeadlessEvent. While the app is open, events go to onLocation / onGeofence
instead.
// Top level (not inside a class), with this annotation:
@pragma('vm:entry-point')
void headlessTask(HeadlessEvent event) {
switch (event.name) {
case 'location':
print('fix while closed: ${event.location?.coords.latitude}');
case 'geofence':
print('${event.action} ${event.identifier}'); // ENTER depot_A
}
}
// At startup, e.g. in main():
await ExBackgroundGeolocation.registerHeadlessTask(headlessTask);
HeadlessEvent field |
Description |
|---|---|
name |
location (a fix) or geofence (one of your geofences was crossed). |
location |
The Location, when there is one. |
identifier / action |
For geofence: the geofence id and ENTER / EXIT. |
- The function must be top level or static and annotated
@pragma('vm:entry-point'); otherwiseregisterHeadlessTaskthrows. - It runs in a separate isolate: no access to your app's widgets or in-memory state. Keep it short.
- Uploads do not need it - queued points upload natively whether or not Dart runs.
- iOS: a no-op. iOS relaunches the whole app for location events (see iOS: resume after termination); native code can also observe the trip-state notification.
Deferred events
A small native-backed queue of opaque strings, for handing data from a background context to your app's Dart side at its next normal launch, without running Dart in between. The plugin never inspects the strings - what they mean is entirely up to your app.
This exists for situations where running Dart at all is unsafe or undesirable -
for example, an iOS notification-action handler in AppDelegate, where spinning
up a headless Flutter engine to register every plugin (including UI-dependent
ones) can crash. Do the time-sensitive native work directly (e.g.
removeUserGeofence below), queue a string describing what happened, and let
Dart pick it up next time the app opens normally.
// From wherever you can't (or don't want to) run Dart right now:
await ExBackgroundGeolocation.addDeferredEvent('order:42:shipped');
// At your app's next normal launch:
final events = await ExBackgroundGeolocation.getDeferredEvents();
for (final e in events) {
// Interpret `e` however your app defines it - the plugin has no opinion.
}
await ExBackgroundGeolocation.clearDeferredEvents();
| Method | Callable from | Effect |
|---|---|---|
addDeferredEvent(String data) |
Dart | Queues data natively (SharedPreferences on Android, UserDefaults on iOS). |
getDeferredEvents() |
Dart | Returns every queued string since the last clear. |
clearDeferredEvents() |
Dart | Empties the queue. |
removeUserGeofence (iOS, native-only)
On iOS, ExBackgroundGeolocationPlugin.removeUserGeofence(_ id: String) is a
public static Swift function - callable directly from your own native code
(e.g. AppDelegate) without a method channel:
import ex_background_geolocation
ExBackgroundGeolocationPlugin.removeUserGeofence("depot_A")
// Native equivalent of ExBackgroundGeolocation.removeGeofence('depot_A') from
// Dart, usable exactly where running Dart isn't an option.
There's no Android/Kotlin equivalent because it isn't needed there: Android's
headless Dart execution is reliable even from a background notification action,
so ExBackgroundGeolocation.removeGeofence from Dart already works in that
context.
How it works
While moving, GPS streams continuously and each fix is persisted then
uploaded. Tracking goes stationary after stopTimeout with no movement: a stop
timer is armed at start, by a STILL activity, and whenever tracking resumes, and
every fix that moved more than max(distanceFilter, stationaryRadius) + its accuracy
restarts it (a confirmed moving activity cancels it; the next STILL re-arms it).
When stationary, GPS is off and a stationary geofence (radius
max(stationaryRadius, 150 m)) is placed at the last fix - never skipped: with no fix
yet, a last-known or one-shot location is looked up first. Tracking resumes when:
- the device leaves the geofence (Android and iOS, also after the app is terminated / the phone rebooted);
- Android: a periodic activity update (every 30 s while stationary) reports walking / running / cycling / in vehicle with confidence >= 70 %, or a low-power location (wifi/cell, every 60 s) lands farther than the radius + accuracy from the stationary point. This backstop does not depend on Google reporting a change - which it does not do if its own state was still "walking" when the stop timer made the device stationary;
- iOS: Core Motion reports motion, or a significant-location change lands
outside the stationary area. Core Motion is only delivered while the app runs:
for
stationaryGraceTime(5 min) after a stop a low-accuracy location feed keeps it running, so a short stop resumes at once; after that iOS suspends the app and the region exit resumes tracking.
Calling start() again while tracking (for example when the app is reopened) keeps the
current moving/stationary state instead of forcing "moving".
Every decision is logged with its reason: Android adb logcat -s ex_background, iOS
Console.app with subsystem com.exlinelabs.ex_background_geolocation (e.g.
-> STATIONARY (stop timer: no movement for 60s), stationary geofence added: centre ... radius 150 m, activity update while stationary: WALKING 85%,
-> MOVING (stationary geofence EXIT at ...)).
Failed uploads are retried automatically (see Upload retry).
On Android the app stays alive via a foreground service and restarts through a boot receiver. On iOS the OS relaunches the app via region monitoring and significant-location changes.
Platform notes
- iOS survive-termination is coarse. After the app is terminated, iOS relaunches
it only on leaving the stationary region (>= 150 m) or a significant-location change
(~500 m), not continuously. It requires the host step.
If the app is terminated while moving with
stopOnTerminate: false, the plugin places the stationary region at the last fix on the way out. - iOS geofences have a hard limit of 20 monitored regions (one is used internally by the stationary geofence).
- Android OEMs (Xiaomi, Huawei, Oppo, etc.) may require the user to enable "Autostart" for reliable boot-restart.
- Upload retry needs a live process. See Upload retry.
- Location-only motion detection without "Physical activity". If the Android 10+
ACTIVITY_RECOGNITIONpermission is missing, stopping and resuming rely on location alone (stop timer, geofence, low-power backstop) - checkgetActivityRecognitionStatus(). - Battery while stationary (Android). The backstop adds a wifi/cell location every 60 s and batched activity updates every 30 s; GPS stays off.
Licensing
The plugin runs free in debug builds. Release builds require a license key tied to your app's bundle id:
await ExBackgroundGeolocation.ready(Config(
licenseKey: '<your key from Exline Labs>',
// ...
));
Without a valid key in release, start() fails with a LICENSE_INVALID error
and tracking will not run; the device log says why the key failed. Tracking that
was already on stops too, at the next launch or ready() (for example after an
app update with a plugin version released after your update period). One key
covers your app's Android applicationId and iOS bundle id - tell us both when
you buy. Purchase a key at exloctra.com or email
support@exlinelabs.com. A license is per app: one key for its Android
applicationId and iOS bundle id.
A key includes 12 months of updates: it works forever with every version of
the plugin released within 12 months of purchase, but not with versions
released after that until you renew updates. Once your update period ends, pin
the last covered version in pubspec.yaml (for example
ex_background_geolocation: 1.4.0, without the ^) so flutter pub upgrade
cannot move your release build to a version your key does not cover. Debug
builds log a warning when the key would fail in release, so you see it while
developing.
"Debug" is decided when the app runs, not by the build mode:
- Android: the check is skipped when the app is debuggable (
flutter run, debug APKs) and enforced in release builds. - iOS: the check is skipped on the simulator and for installs signed with
a development profile - anything run from Xcode or
flutter run, including--release. It is enforced for TestFlight, App Store and ad-hoc installs, so check your key with a TestFlight build before shipping.
License
Commercial: free to evaluate in debug builds; release builds need a license key. See LICENSE. ยฉ Exline Labs (Pvt) Ltd.
Libraries
- ex_background_geolocation
- Battery-conscious background location tracking, motion detection and geofencing for Android and iOS.