Geofencing triggers an event when a device enters or exits a geographic area. It's used for location-based reminders, delivery arrival detection, and proximity alerts. Android's background execution limits add significant complexity.
Geofencing API
class GeofenceManager(private val context: Context) {
private val geofencingClient = LocationServices.getGeofencingClient(context)
fun addGeofence(
id: String,
latitude: Double,
longitude: Double,
radiusMeters: Float = 100f
) {
val geofence = Geofence.Builder()
.setRequestId(id)
.setCircularRegion(latitude, longitude, radiusMeters)
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT)
.setLoiteringDelay(30_000) // 30s dwell before firing DWELL transition
.build()
val request = GeofencingRequest.Builder()
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
.addGeofence(geofence)
.build()
// PendingIntent to handle the transition
val pendingIntent = PendingIntent.getBroadcast(
context,
id.hashCode(),
Intent(context, GeofenceBroadcastReceiver::class.java).apply {
putExtra("geofence_id", id)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
) {
geofencingClient.addGeofences(request, pendingIntent)
}
}
fun removeGeofence(id: String) {
geofencingClient.removeGeofences(listOf(id))
}
}
Receiving Transitions
class GeofenceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val geofencingEvent = GeofencingEvent.fromIntent(intent) ?: return
if (geofencingEvent.hasError()) {
Timber.e("Geofencing error: ${geofencingEvent.errorCode}")
return
}
val transition = geofencingEvent.geofenceTransition
val triggeringGeofences = geofencingEvent.triggeringGeofences ?: return
when (transition) {
Geofence.GEOFENCE_TRANSITION_ENTER -> {
triggeringGeofences.forEach { geofence ->
onGeofenceEntered(context, geofence.requestId)
}
}
Geofence.GEOFENCE_TRANSITION_EXIT -> {
triggeringGeofences.forEach { geofence ->
onGeofenceExited(context, geofence.requestId)
}
}
}
}
private fun onGeofenceEntered(context: Context, geofenceId: String) {
// Use WorkManager to do any work (network calls, notifications)
// BroadcastReceiver must return quickly; don't do network calls here
val work = OneTimeWorkRequestBuilder<GeofenceArrivalWorker>()
.setInputData(workDataOf("geofence_id" to geofenceId))
.build()
WorkManager.getInstance(context).enqueue(work)
}
}
Android Background Limits: The Challenge
Android imposes strict limits on background location access:
| API Level | Restriction |
|---|---|
| API 26+ | Background services killed within minutes |
| API 29+ | ACCESS_BACKGROUND_LOCATION permission required for background access |
| API 31+ | Exact alarm restrictions |
| Doze mode | Location updates suspended during idle periods |
What still works in background:
BroadcastReceiverfor geofence transitions (limited execution time)- WorkManager (system schedules when resources allow)
- Foreground Service with
FOREGROUND_SERVICE_LOCATIONtype
Foreground Service for Active Tracking
For ride-hailing driver apps or delivery tracking that needs continuous background location:
class LocationTrackingService : Service() {
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { uploadLocation(it) }
}
}
override fun onCreate() {
// Required: show persistent notification before requesting location
startForeground(NOTIFICATION_ID, buildTrackingNotification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5_000L).build()
fusedLocationClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
return START_STICKY
}
override fun onDestroy() {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
private fun buildTrackingNotification(): Notification {
return NotificationCompat.Builder(this, "tracking_channel")
.setSmallIcon(R.drawable.ic_location)
.setContentTitle("Location tracking active")
.setContentText("Your location is being shared")
.setOngoing(true) // cannot be dismissed by user
.build()
}
override fun onBind(intent: Intent?) = null
}
Geofence Limits
- Maximum 100 geofences per app
- Accuracy depends on available sensors; GPS off → cell/WiFi-based ~100–500m accuracy
- Geofences are lost on device reboot — re-register in
BOOT_COMPLETEDreceiver
Key Takeaways
| Concept | Rule |
|---|---|
ACCESS_BACKGROUND_LOCATION | Required for background geofencing on API 29+; user grants separately |
| BroadcastReceiver for transitions | Trigger WorkManager — don't do long work in the receiver |
| Foreground Service | Required for continuous background location (ride apps, delivery) |
| Geofence limit | Max 100 per app; remove when no longer needed |
| Re-register on boot | Geofences lost on reboot; listen for BOOT_COMPLETED |
| Doze mode | Geofencing still works in Doze (uses coarser positioning); foreground services resume |