geofence_foreground_service
A Flutter plugin that enables you to easily handle geofencing events in your Flutter app by utilizing native OS APIs on Android by creating a foreground service while being battery efficient since it uses the Geofence and WorkManager APIs. And on iOS by utilizing the CLLocationManager
It's important to note that the workmanager and flutter_foreground_task plugins were a great source of inspiration while creating this plugin.
| Android | iOS |
|---|---|
![]() |
![]() |
Features
- Supports geofencing in foreground as well as background πͺ
- Geofence a circular area πΊοΈ
- Geofence a polygon π€― You can add a geofence using a list of coordinates, the plugin registers the smallest circle that covers every coordinate on both platforms, plus an optional
polygonMarginMeters. You do not size it:radiusis ignored for a polygon. The device never evaluates the polygon's real outline, so near its edge the result can differ from a true point-in-polygon check done on a server, always in the direction of reporting inside (see Behaviour worth knowing) - Survives restarts π: β οΈAndroidβ οΈ Zones are persisted and re-registered after a device reboot or an app update, because Google Play services drops geofences in both cases
- Notification customization π: β οΈAndroidβ οΈ Displaying a notification when running a foreground service is mandatory, you can customize what is being displayed on it (title, content or the icon), the plugin displays your app icon by default.
- Notification responsiveness β±οΈ: β οΈAndroidβ οΈ You can set the responsiveness of the android notifications as per the docs here
Setup
π§ Android Setup
- Enable MultiDex, you can check how to do so here
- The plugin's manifest already declares the service, the boot receiver and the permissions
(
FOREGROUND_SERVICE,FOREGROUND_SERVICE_LOCATION,ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATION,ACCESS_BACKGROUND_LOCATION,POST_NOTIFICATIONS,RECEIVE_BOOT_COMPLETED), so there is nothing to add to yourAndroidManifest.xml. You still have to request the location, background location and notification permissions at runtime. If you want to customise the service declaration, declaring it in your own manifest overrides the plugin's. - Make sure the
minSdkVersionin theapp/build.gradlefile is 29+ - Your app needs
compileSdk34 or newer.
π§ iOS Setup
- Navigate to the Podfile and make sure to set the iOS version to 12+
platform :ios, '12.0'
- Make sure to add the following permission to your Info.plist
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app need your location to provide best feature based on location</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>This app need your location to provide best feature based on location</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app need your location to provide best feature based on location</string>
- Turn on the
Location updatesandBackground fetchcapabilities from XCode
Example
Define the method that will handle the Geofence triggers
import 'package:geofence_foreground_service/exports.dart';
import 'package:geofence_foreground_service/geofence_foreground_service.dart';
import 'package:geofence_foreground_service/models/zone.dart';
// This method is a top level method
@pragma('vm:entry-point')
void callbackDispatcher() async {
GeofenceForegroundService().handleTrigger(
backgroundTriggerHandler: (zoneID, triggerType) {
log(zoneID, name: 'zoneID');
if (triggerType == GeofenceEventType.enter) {
log('enter', name: 'triggerType');
} else if (triggerType == GeofenceEventType.exit) {
log('exit', name: 'triggerType');
} else if (triggerType == GeofenceEventType.dwell) {
log('dwell', name: 'triggerType');
} else {
log('unknown', name: 'triggerType');
}
return Future.value(true);
},
);
}
Then create an instance of the plugin to initiate it and assign GeoFences to it
final List<LatLng> timesSquarePolygon = [
const LatLng(40.758078, -73.985640),
const LatLng(40.757983, -73.985417),
const LatLng(40.757881, -73.985493),
const LatLng(40.757956, -73.985688),
];
Future<void> initPlatformState() async {
// Remember to handle permissions before initiating the plugin
bool hasServiceStarted = await GeofenceForegroundService().startGeofencingService(
contentTitle: 'Test app is running in the background',
contentText: 'Test app will be running to ensure seamless integration with ops team',
notificationChannelId: 'com.app.geofencing_notifications_channel',
serviceId: 525600,
callbackDispatcher: callbackDispatcher,
);
if (hasServiceStarted) {
await GeofenceForegroundService().addGeofenceZone(
zone: Zone(
id: 'zone#1_id',
radius: 10000, // measured in meters; ignored for a polygon
coordinates: timesSquarePolygon,
polygonMarginMeters: 25, // optional safety margin around the polygon
triggers: [
GeofenceEventType.dwell,
GeofenceEventType.enter,
GeofenceEventType.exit
], // Currently, only available on Android
expirationDuration: const Duration(days: 1), // Currently, only available on Android
dwellLoiteringDelay: const Duration(hours: 1), // Currently, only available on Android
// Report the state the device is already in when the zone is added:
// `enter` if it is inside, `exit` if it is outside.
initialTriggers: [GeofenceEventType.enter, GeofenceEventType.exit],
),
);
}
}
Something important to point out is the callbackDispatcher method will run in an entirely different isolate than the actual app, so if you were to handle UI related code inside of it you'll need to use Ports, you can find more information here
You can pass a custom icon to the foreground service notification if you wish while initializing the service, this icon will be placed inside the android/app/src/main/res folder, you can check the example for more information, by default, it will take the app icon
const NotificationIconData(
resType: ResourceType.mipmap,
resPrefix: ResourcePrefix.ic,
name: 'launcher',
)
Knowing the starting state
By default only initialTrigger: GeofenceEventType.enter is reported, so a device that starts
outside a zone gets no event until it crosses the boundary. Pass both triggers to learn which
side it is on right away:
Zone(
id: 'site',
radius: 150,
coordinates: [site],
initialTriggers: [GeofenceEventType.enter, GeofenceEventType.exit],
)
Exactly one event arrives per zone: enter if the device is inside, exit if it is outside.
It is delivered through the same handleTrigger callback as any other transition. initialTrigger
(single value) keeps working and is equivalent to a one element initialTriggers.
Reducing battery use (Android)
While the foreground service runs it keeps one location request open. Geofence transitions are delivered by Google Play services regardless of it, so you can lower it:
await GeofenceForegroundService().startGeofencingService(
// ...
locationUpdateMode: LocationUpdateMode.balanced, // or .off
);
| Mode | Android | iOS |
|---|---|---|
highAccuracy (default) |
high accuracy, every 20 s | best accuracy updates |
balanced |
balanced power, every 60 s | 100 m accuracy and distance filter |
off |
no location request | no continuous updates; region monitoring only |
balanced and off were exercised on an emulator and still delivered transitions, but
transition latency was not measured, so check it against your own requirements before shipping
off.
Adding several zones at once
final ok = await GeofenceForegroundService().addGeofenceZones(zones: [zoneA, zoneB]);
Every zone is validated first, so an invalid zone (or a duplicate id) means nothing is registered.
Platform differences
| Feature | Android | iOS |
|---|---|---|
| Enter / exit events | β | β |
Dwell event (dwellLoiteringDelay) |
β | β ignored |
triggers |
β | enter and exit only |
initialTrigger / initialTriggers |
β | β (enter / exit) |
locationUpdateMode |
β | β |
expirationDuration |
β | β ignored |
notificationResponsivenessMs |
β | β ignored |
| Foreground notification (title, text, icon) | β | n/a |
| Max monitored zones | 100 | 20 (REGION_LIMIT_REACHED) |
| Re-registered after reboot / app update | β | handled by the OS |
Behaviour worth knowing
- A zone with several coordinates is monitored as the smallest circle that covers all of
them, widened by
polygonMarginMeters(default 0), on both platforms.radiusis ignored for such a zone (it must still be a number greater than 0). The polygon's outline is not used by the device, so a server that does an exact point-in-polygon test can disagree near the edge: the circle is never smaller than the polygon, so the device can say inside for a point just outside a concave corner, but never outside for a point inside it. RaisepolygonMarginMetersto absorb GPS jitter at the edge. - On iOS a polygon whose covering circle is larger than the OS maximum
(
maximumRegionMonitoringDistance) is rejected withZONE_TOO_LARGEinstead of being monitored smaller than its outline. - On iOS the initial state is asked of Core Location when the zone is added. If it cannot answer
yet (no location fix), the plugin asks again on every location update and every 2 seconds, up to
10 times (the timer covers
LocationUpdateMode.off). Past that, no initial event is reported and laterenterandexitevents still arrive. - On Android 12+ the system does not let a killed service restart itself from the background, so the foreground notification and location updates only come back with the next geofence transition or when the app is opened. Geofence registrations themselves are unaffected.
- The background location permission is required on Android. If it is revoked, the service stops and posts a "Permission Required" notification.
- Methods resolve to
false(and log the reason withdart:developer) for invalid input instead of throwing.
Upgrading from 1.3.x
- Polygon zones are a covering circle. A zone with several coordinates is monitored as the
smallest circle containing all of them, and
radiusis ignored for it. The circle is never smaller than the polygon but can be larger than in 1.3.x. polygonMarginMeters(default 0) widens that circle. Leave it at 0 to keep the exact covering circle.- iOS
ZONE_TOO_LARGE: a polygon larger than the OS maximum region distance is rejected instead of being clamped. Check the result ofaddGeofenceZoneor split the polygon. - iOS initial state: when there is no location fix yet, the initial
enter/exitis retried and may arrive later than before. No code change is needed.
Notes
Handling permissions is not a part of the package, so please refer to permission_handler plugin to grant the required permissions (it's used in the example too)
- location
- locationAlways
- notification
Contributing
We welcome contributions. Please read CONTRIBUTING.md for pull request expectations (including small PRs and a maximum of 15 files per PR), testing, changelog updates, and review practices.
Libraries
- constants/api_methods
- constants/geofence_event_type
- constants/json_keys
- constants/location_update_mode
- exports
- geofence_foreground_service
- geofence_foreground_service_method_channel
- geofence_foreground_service_platform_interface
- models/background_task_handlers
- models/notification_icon_data
- models/zone

