Geofencing Engine & Sliding Window Algorithm
Google Play Services limits Android applications to registering a maximum of 100 active geofences per app. To support unlimited saved locations and geofenced reminders, Geotify implements a Sliding Window Geofencing Algorithm.
Dual-Ring Spatial Model
The sliding window partitions active locations into two distinct circular boundaries:
+---------------------------------------+
| Outer Search Radius N (e.g. 5.0 km) |
| |
| +---------------------------+ |
| | Inner Master Radius r | |
| | (e.g. 3.0 km) | |
| | | |
| | (User) | |
| | | |
| | POI 1 POI 2 | |
| +---------------------------+ |
| |
| POI 3 |
+---------------------------------------+
POI 4 (Stored in Room DB - Outside Bounding Radius N)
- Master Geofence (
r, default 3 km): Centered at the user's location during the last calculation. MonitorsGEOFENCE_TRANSITION_EXIT. Crossing this boundary triggers an expedited recalculation to re-center the sliding window. - POI Geofences (
N, default 5 km, max 99 active): Up to 99 active locations with active reminders closest to the user within radiusN. - Capacity Budget:
99 POIs + 1 Master Geofence = 100 Geofences Maximum.
Spatial Math & Bounding Box Optimization
To avoid calculating exact Haversine distances across thousands of database entries, Geotify executes a two-stage spatial search implemented in SpatialSearchUseCase:
SQL Bounding Box Pre-Filter
The search area is first approximated using a latitude and longitude bounding box [minLat, maxLat] × [minLon, maxLon]:
Where:
- 111,320 meters: Approximate distance of 1° latitude at the equator.
- lat_rad: Latitude in radians (Math.toRadians(centerLat)).
// SpatialSearchUseCase.kt
val radiusInMeters = radiusN * 1000.0
val latDegreesChange = radiusInMeters / 111320.0
val latRad = Math.toRadians(centerLat)
val cosLat = cos(latRad)
val lonDegreesChange = if (cosLat > 0.0) {
radiusInMeters / (111320.0 * cosLat)
} else {
360.0
}
Exact Geodesic Sorting
Locations returned by the bounding-box query are evaluated using Location.distanceBetween(centerLat, centerLon, pointLat, pointLon, results). Points within radius N are sorted ascending by distance and limited to take(MAX_POI_GEOFENCES) (99 points).
WorkManager & Broadcast Execution
Recalculations are coordinated by GeofenceOrchestrator and executed off the main thread by GeofenceRecalculationWorker.
sequenceDiagram
autonumber
actor User
participant GMS as GMS GeofencingClient
participant Recv as GeofenceBroadcastReceiver
participant Orch as GeofenceOrchestrator
participant WM as WorkManager Worker
participant DB as Room Database
GMS->>Recv: On Geofence Transition Intent
alt Master Geofence Exited
Recv->>Orch: triggerExpeditedRecalculation()
Orch->>WM: Enqueue GeofenceRecalculationWorker (REPLACE)
WM->>DB: Query nearest 99 POIs
WM->>GMS: Unregister old fences & Register updated Master + POI fences
else POI Geofence Triggered (Arrival / Departure)
Recv->>DB: Deactivate triggered reminder
Recv->>Recv: Post Notification
Recv->>Orch: triggerExpeditedRecalculation()
end
BroadcastReceiver Timeout Safeguard (goAsyncCoroutine)
Android limits BroadcastReceiver.onReceive() execution time. GeofenceBroadcastReceiver uses goAsyncCoroutine with a 9-second timeout to safely process database writes and notification posting before returning:
fun BroadcastReceiver.goAsyncCoroutine(
timeoutMs: Long = 9000L,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
withTimeout(timeoutMs) { block() }
} finally {
pendingResult.finish()
}
}
}
Boot Recovery (BootCompletedReceiver)
When the device restarts, GMS clears active geofences. BootCompletedReceiver listens for ACTION_BOOT_COMPLETED and calls geofenceOrchestrator.triggerRecalculation(), restoring all active geofences automatically.