Skip to main content

ex_background_geolocation 1.0.0-beta.1 copy "ex_background_geolocation: ^1.0.0-beta.1" to clipboard
ex_background_geolocation: ^1.0.0-beta.1 copied to clipboard

retracted

Background location tracking for Android and iOS: motion detection that turns GPS off when parked, geofencing and an offline-safe HTTP upload queue.

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; notApplicable on Android 9 and below and on iOS).
  • requestActivityRecognition() โ†’ true if 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 distanceFilter for 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 the uploadInterval throttle skipped (odometer and position changed; pointsStored simply didn't).
  • "upload" - after every upload attempt: automatic uploads, retries and a manual sync() (one snapshot per upload pass, which may POST several queued records), so pendingCount and pointsUploaded are fresh and pendingCount falls 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 constant TripState.ACTION_STATE).
  • Extras: flat Long / Int / Double / Boolean / String values named exactly as the fields above; omitted optional fields are absent (use containsKey).

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'); otherwise registerHeadlessTask throws.
  • 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_RECOGNITION permission is missing, stopping and resuming rely on location alone (stop timer, geofence, low-power backstop) - check getActivityRecognitionStatus().
  • 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. 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.

0
likes
0
points
0
downloads

Documentation

Documentation

Publisher

verified publisherexlinelabs.com

Weekly Downloads

Background location tracking for Android and iOS: motion detection that turns GPS off when parked, geofencing and an offline-safe HTTP upload queue.

Homepage

License

unknown (license)

Dependencies

flutter

More

Packages that depend on ex_background_geolocation

Packages that implement ex_background_geolocation