Architecture
Shiny.Locations exposes two abstractions β IGpsManager and IGeofenceManager β and routes every platform to whatever the OS actually offers for location services. GPS and geofencing share the same persistence layer, the same access model, and (in the GPS-direct fallback) the same dispatch loop. This page explains why the surface is split into two managers, why each platform has the implementation it has, and what the library deliberately doesnβt try to solve.
If youβve read Shiny.Jobs architecture, the playbook is similar β one DI registration, constraint-shaped requests, platform-tiered engines under a uniform surface.
TL;DR β the shape
Section titled βTL;DR β the shapeβ App code βββΊ AddGps<TGpsDelegate>() AddGeofencing<TGeofenceDelegate>() β βΌ DI registration (singleton) β βΌ βββββββββββββββββ΄ββββββββββββββββββββ β β βΌ βΌ IGpsManager IGeofenceManager StartListener(GpsRequest) StartMonitoring(GeofenceRegion) GetLastReading() GetMonitorRegions() GpsReadingReceived (foreground) RequestState() β β β βββββββββββββββββββββββββββββββββ€ βΌ βΌ βΌ ββββββββββββββββββββ ββββββββββββββββββββββββββββββ β iOS / Mac β β iOS 18+ : CLMonitor β β CLLocationUpdaterβ β iOS <18 : CLLocationManagerβ β CLServiceSession β β (region monitoring) β β CLBackgroundActivity β Android : Google Play β ββββββββββββββββββββ β Geofencing API β ββββββββββββββββββββ β βΌ (fallback) β β Android β β GPS-direct β β FusedLocation β β Windows : Windows.Devices β β ProviderClient β β .Geofencing β β ShinyGpsService β ββββββββββββββββββββββββββββββ β (foreground) β β ββββββββββββββββββββ βΌ ββββββββββββββββββββ Repository<GeofenceRegion> β Windows β (auto-restore on launch) β Geolocator β ββββββββββββββββββββ ββββββββββββββββββββ β Blazor (WASM) β β navigator. β β geolocation β ββββββββββββββββββββFive design pillars:
- Two managers, one persistence layer. GPS and geofencing are separate problems with separate OS APIs, but both lean on the same
IRepositoryfor restore-on-launch. Re-registering regions or re-starting a GPS listener after the OS reboots the app is the libraryβs job, not yours. - Requests describe what you want, not when.
GpsRequest.BackgroundModeandGeofenceRegion.NotifyOnEntry/Exitexpress intent; each platform translates that intent into the right OS primitive. There is no per-listener interval contract β mobile OSes donβt honour them. - Delegates run in the background, events run in the foreground.
IGpsDelegate/IGeofenceDelegateare DI-resolved singletons that fire from OS-driven background dispatch.GpsReadingReceivedis a plainEventHandlerfor in-app UI. The split is intentional β the OS canβt reach+=handlers when your app is suspended. - GPS-direct geofencing is a real fallback, not a toy. On Android without Google Play Services, the library transparently switches geofencing to a realtime background GPS loop. The same path is also opt-in via
AddGpsDirectGeofencing<T>()when you need it. Position,Distance, andGpsReadingare the lingua franca. The same struct/record shapes flow from native APIs into your delegate everywhere. No platform-specific reading types leak through the public surface; platform-specific requests (AndroidGpsRequest,AppleGpsRequest) extend the base record without breaking it.
Why two managers instead of one?
Section titled βWhy two managers instead of one?βYou could imagine an ILocationManager with StartGps and StartGeofencing on it. The library doesnβt do that, for two reasons.
The OS APIs are different shapes. GPS is a stream β start a listener, get readings until you stop. Geofencing is a set β register regions, get callbacks when boundaries are crossed. iOS gives you CLLocationUpdater for the first and CLMonitor (or legacy CLLocationManager.StartMonitoringRegion) for the second. Android gives you FusedLocationProviderClient for the first and GeofencingClient for the second. The OS distinguishes them; the library does too.
They scale differently. A single GPS listener costs the same as a thousand readings. Each geofence region costs platform budget β iOS caps you at 20 simultaneous monitored regions, Android at 60. Forcing both behind one interface would either limit GPS to the geofence ceiling or hide the geofence ceiling behind a stream-shaped API that quietly drops regions. Two managers, two cost models.
The one place they merge is GpsGeofenceManagerImpl β the fallback that implements IGeofenceManager by running an IGpsManager underneath. Thatβs an internal implementation detail; the surface stays separated.
Why GpsRequest is shaped around GpsBackgroundMode instead of intervals
Section titled βWhy GpsRequest is shaped around GpsBackgroundMode instead of intervalsβpublic record GpsRequest( GpsBackgroundMode BackgroundMode = GpsBackgroundMode.None, bool RequestPreciseAccuracy = false, bool AutoRestart = true);
public enum GpsBackgroundMode { None, Standard, Realtime }Three modes, not three thousand knobs. Each one maps cleanly onto each platformβs actual capability:
| Mode | iOS | Android | Windows | Blazor |
|---|---|---|---|---|
None | CLLocationUpdater in-process (foreground) | FusedLocationProviderClient (foreground) | Geolocator (foreground) | navigator.geolocation (foreground only) |
Standard | Significant location changes (~hourly) | FusedLocation with relaxed interval (~3β4 readings/hour) | Not supported | Silently degraded to None with a logged warning |
Realtime | CLBackgroundActivitySession + full background updates (~1/sec) | ShinyGpsService foreground service + 1-sec interval | Not supported | Silently degraded to None with a logged warning |
Per-listener intervals exist on Android (LocationRequest.Builder.IntervalMillis) but not on iOS β Standard background mode is whatever the OS decides βsignificantβ means today. The library doesnβt lie about this. If you genuinely need a tunable interval and youβre on Android, drop down to AndroidGpsRequest which exposes it directly. Cross-platform code stays at the GpsRequest level.
AutoRestart = true (the default) is the iOS / Android contract for surviving force-quit. The platform manager persists CurrentSettings to the Shiny key/value store on StartListener, reads it back on Start() (the IShinyStartupTask hook), and re-issues RequestLocationUpdates so the OS picks the listener back up.
Why platform-specific request types extend GpsRequest
Section titled βWhy platform-specific request types extend GpsRequestβrecord GpsRequest(GpsBackgroundMode BackgroundMode, ...);record AndroidGpsRequest(GpsBackgroundMode BackgroundMode, ...) : GpsRequest(...);record AppleGpsRequest(GpsBackgroundMode BackgroundMode, ...) : GpsRequest(...);C# record inheritance, used deliberately. App code can pass GpsRequest everywhere and the listener works on every platform. App code that needs to reach the Android-specific IntervalMillis / DistanceFilterMeters / GpsPriority knobs β or the iOS StationaryMetersThreshold / AllowsBackgroundLocationUpdates knobs β passes the platform record instead, guarded by #if ANDROID / #if IOS.
The platform managerβs StartListenerInternal unwraps the base record into the platform record (with defaults) so the path is the same either way:
if (request is not AndroidGpsRequest android) android = new AndroidGpsRequest(request.BackgroundMode);No reflection, no attribute scanning. The contract is the type system.
Why geofencing has four engines under one surface
Section titled βWhy geofencing has four engines under one surfaceβIGeofenceManager looks identical on every platform. Behind it are four very different implementations:
| Platform | Engine | Why |
|---|---|---|
| iOS 18+ / Mac Catalyst 18+ | CLMonitor with CLMonitorConfiguration + MonitorEvents async iterator | The modern, deprecation-safe geofencing API on Apple platforms. Pure async β no delegate callbacks. Needs a CLServiceSession to deliver events while the app is suspended. |
| iOS / Mac Catalyst < 18 | CLLocationManager.StartMonitoring(CLCircularRegion) + CLLocationManagerDelegate | The legacy API. Still works. The library auto-selects it based on OperatingSystem.IsIOSVersionAtLeast(18). |
| Android with Google Play Services | GeofencingClient + GeofenceBroadcastReceiver | Native OS geofencing with battery-aware throttling. Uses a PendingIntent so geofence transitions wake the app via broadcast even after process death. |
| Android without Google Play Services | GpsGeofenceManagerImpl β realtime background GPS + in-process region check | The fallback. When GoogleApiAvailability.IsGooglePlayServicesAvailable returns ServiceMissing, AddGeofencing silently rewires to AddGpsDirectGeofencing so the API contract holds. |
| Windows | Windows.Devices.Geolocation.Geofencing.GeofenceMonitor | Native OS geofencing. No region cap. |
The selection happens at DI registration time inside AddGeofencing<T>() β no runtime branching in your delegate, no per-platform code in your app.
Why the geofence repository is the source of truth
Section titled βWhy the geofence repository is the source of truthβpublic record GeofenceRegion(...) : IRepositoryEntity;Geofence regions are first-class persisted records. Every StartMonitoring(region) call writes to the Shiny IRepository<GeofenceRegion> before the native API is hit. The native API holds the OS-level monitor; the repository holds the canonical list.
This is the only sane model when:
- The OS reboots and the app needs to re-register monitors on next launch.
IShinyStartupTask.Start()reads the repository and rehydrates the native monitor set. - The native API forgets regions during a force-update or system upgrade. The repository persists across these; reconciliation on next start re-pushes them.
- A geofence is reported by the OS that the app doesnβt know about (stale install, renamed identifier). The handler looks up the identifier in the repository β and if itβs not there, logs a warning and ignores the trigger rather than crashing.
The iOS 18 GeofenceManager makes the symmetry explicit with a Reconcile step on Start(): it diffs mon.MonitoredIdentifiers against the repository, drops orphans from the OS, and adds missing regions. Same pattern works on Android and Windows.
Why iOS 18+ uses CLMonitor instead of staying on CLLocationManager
Section titled βWhy iOS 18+ uses CLMonitor instead of staying on CLLocationManagerβApple deprecated region monitoring on CLLocationManager in iOS 17 and replaced it with CLMonitor. The legacy API still works but youβre on borrowed time, and Mac Catalyst on Sequoia (15.0+) requires CLMonitor for new monitor types.
CLMonitor is a cleaner shape too:
- No
CLLocationManagerDelegateplumbing β events arrive via an async iterator (MonitorEvents). - Monitor conditions are declarative β
CLMonitor.SetCondition(CLMonitorConfiguration). - The event stream surfaces why the system thinks a transition happened (enter, exit, unmonitored), so you can log meaningfully on edge cases.
The legacy CLLocationGeofenceManager is kept for iOS 15/16/17 and pre-Sequoia Mac Catalyst. Both implementations write to the same repository; switching iOS versions doesnβt drop your regions.
Why GPS-direct geofencing exists at all
Section titled βWhy GPS-direct geofencing exists at allβOS geofencing is what you want. Itβs battery-efficient (the radio is woken by motion + cell-tower transitions, not a constant GPS fix), it survives app kill, and it ships with each platformβs tuning.
But you canβt always have it:
- Android without Google Play Services. Huawei devices, Amazon Fire tablets, AOSP builds β Googleβs geofencing API isnβt there. The library still has to return something from
AddGeofencing<T>(). So it transparently rewires to GPS-direct. - Apps that already run realtime background GPS. If the GPS listener is already burning the battery for navigation or route tracking, you might as well let the in-process loop also check geofence transitions instead of paying for the OS geofencing API on top.
- More than 20/60 regions. iOS caps at 20, Android at 60. GPS-direct has no cap because regions live in your
IRepository, not in the OS monitor set. (For very large region sets, Spatial Geofencing with an R*Tree index is the better answer β GPS-direct walks all regions linearly per reading.)
GpsGeofenceManagerImpl implements IGeofenceManager by:
- Starting a
Realtimebackground GPS listener the first time a region is monitored. - Persisting the region to
IRepository<GeofenceRegion>. - On every GPS reading, walking the region list and calling
region.IsPositionInside(reading.Position). - Tracking last-known
GeofenceStateper region inGpsGeofenceDelegate.CurrentStatesso the delegate fires only on transition, not on every reading inside the region.
This is the same delegate contract (IGeofenceDelegate.OnStatusChanged) β your app code canβt tell which engine is underneath, by design.
The trade-off β and itβs a real one β is that realtime GPS is the most battery-hostile mode the library exposes. The XML doc on the method is blunt: βDO NOT USE THIS IF YOU DONβT KNOW WHAT YOU ARE DOING.β
Why stationary detection lives in the library, not in the delegate
Section titled βWhy stationary detection lives in the library, not in the delegateβGpsReading.IsStationary is set before the reading reaches your delegate or GpsReadingReceived handler. Three reasons:
-
iOS 18+ has it natively via
CLLocationUpdaterstationary detection. The library readsCLLocationUpdate.Stationaryand flows it straight through. -
iOS legacy and Android donβt β but the OS isnβt going to start reporting it just because weβd like it. The library runs
StationaryDetectoragainst incoming readings:if (distance < metersThreshold && (now - lastMovement) >= secondsThreshold)IsStationary = true;Defaults: 10 m / 30 s. Configurable per request.
-
Doing this once, in the library, means every consumer sees the same flag. If every app reimplemented βare we stationary?β against the raw reading stream, every app would get it slightly wrong, especially around GPS jitter and apparent movement at low speed. The thresholds are exposed (
StationaryMetersThreshold,StationarySecondsThreshold) for the cases where 10 m / 30 s doesnβt fit.
Why GpsDelegate exists with AND minimums / OR maximums
Section titled βWhy GpsDelegate exists with AND minimums / OR maximumsβRaw GPS streams arrive at whatever rate the platform chooses. iOS doesnβt honour your time/distance filter perfectly. Androidβs setMinUpdateDistanceMeters works but interacts with the foreground service notification cadence. So the in-library GpsDelegate base class does the filtering for you:
class MyGpsDelegate : GpsDelegate{ public MyGpsDelegate(ILogger log) : base(log) { MinimumDistance = Distance.FromMeters(200); // AND MinimumTime = TimeSpan.FromMinutes(1); // AND MaximumDistance = Distance.FromKilometers(2); // OR β bypasses minimums MaximumTime = TimeSpan.FromMinutes(10); // OR β bypasses minimums } protected override Task OnGpsReading(GpsReading r) { ... }}The semantics are deliberate:
- Minimums are ANDed. When both
MinimumDistanceandMinimumTimeare set, both must be satisfied beforeOnGpsReadingfires. This is what every βlog a position every km, but not more than once a minuteβ app actually wants. OR-on-minimums would fire on whichever threshold tripped first, defeating the throttle. - Maximums are ORed and they override. If youβve moved 2 km or 10 minutes have elapsed, the reading fires regardless of minimums. This is the safety valve β without it, sitting still inside the minimum-distance bubble for hours would suppress every reading. Apps that need to know βwhere is the user right now, eventuallyβ canβt tolerate that.
- A
SemaphoreSlimserializes calls so two readings canβt race intoOnGpsReadingconcurrently. The delegate is a singleton across the app; concurrent reads happen.
The base class exposes LastReading (last reading that fired) and MostRecentReading (last reading regardless of filtering) so derived classes can introspect both without re-wiring the stream.
Why GpsReadingReceived is a plain event, not Rx
Section titled βWhy GpsReadingReceived is a plain event, not RxβShiny.Locations used to ship IObservable<GpsReading>. It doesnβt anymore. The event is a plain event EventHandler<GpsReading>:
public event EventHandler<GpsReading> GpsReadingReceived;Three reasons:
- Background dispatch. The OS reaches
IGpsDelegatevia DI when the app is suspended. It canβt reach+=subscribers in any meaningful sense β theyβre in-process state. The delegate model is the correct contract for background work; the event is the correct contract for βwhile the page is open.β - Rx pulls in
System.Reactive(~500 KB). For a stream that fires at most once a second, a+= handler / -= handlerpair is enough. - It composes with
IGpsDelegatecleanly. The manager raises the event and dispatches to delegates. UI subscribers see both; background delegates see what the OS routes to them.
Remember to -= your handler on view disappear/dispose. There is no automatic weak-event plumbing.
Why the GPS-direct geofence delegate de-dupes states
Section titled βWhy the GPS-direct geofence delegate de-dupes statesβGpsGeofenceDelegate.OnReading is called once per GPS reading and walks every monitored region. Without state tracking, every reading inside region A would fire OnStatusChanged(Entered, A). Thatβs wrong twice over: the OS geofencing path only fires on transition, and the delegate is supposed to be a transition signal.
So the delegate tracks Dictionary<string, GeofenceState> CurrentStates and only invokes IGeofenceDelegate.OnStatusChanged when state != current. Entering region A fires once; leaving fires once; staying inside fires zero times.
The state map is in-memory only β it rebuilds on next launch from the first reading after the GPS listener restarts. The OS path doesnβt have this rebuild step because the OS itself owns transition tracking; the in-process path inherits the responsibility along with the implementation.
Per-platform engines, one surface β the GPS table
Section titled βPer-platform engines, one surface β the GPS tableβ| Platform | GPS Engine | Background guarantees |
|---|---|---|
| iOS 18+ / Mac Catalyst 18+ | CLLocationUpdater + CLServiceSession (Always or WhenInUse) + CLBackgroundActivitySession for Realtime | OS-scheduled. CLBackgroundActivitySession keeps the process alive for realtime; Standard mode uses significant-location-change wakeups. |
| iOS / Mac Catalyst < 18 | CLLocationManager + AllowsBackgroundLocationUpdates | Same OS guarantees; older delegate-shaped API. Auto-selected for iOS 15β17. |
| Android | FusedLocationProviderClient (Google Play Services) or LocationManager (fallback) | Foreground service (ShinyGpsService, ForegroundServiceType = TypeLocation) for Realtime; passive intervals for Standard. Doze-aware. |
| Windows | Windows.Devices.Geolocation.Geolocator | Foreground only. |
| Blazor WASM | navigator.geolocation.watchPosition via JS interop | Foreground only. Tab must be alive and focused. |
Note the two Android GPS managers: GooglePlayServiceGpsManager (preferred, uses Fused Location) and LocationServicesGpsManager (fallback, raw LocationManager). Selection happens in AddGps based on GoogleApiAvailability β same pattern as geofencing.
Why Android Realtime mode requires a foreground service
Section titled βWhy Android Realtime mode requires a foreground serviceβAndroid killed truly invisible background GPS in API 26. The only contract that survives Doze and battery-saver is a foreground service with a user-visible notification.
ShinyGpsService extends ShinyAndroidForegroundService<IGpsManager, IGpsDelegate> with ForegroundServiceType = TypeLocation. The service:
- Starts when
GpsRequest.BackgroundMode == Realtime. - Subscribes to
GpsReadingReceivedand fans readings out to every registeredIGpsDelegate. - Stops when
StopListeneris called or (optionally) when the appβs foreground task is removed.
The notification content is customisable via IAndroidForegroundServiceDelegate β see the GPS page.
Without this service, Realtime GPS would die the moment the screen turned off. The notification is the price of the guarantee.
Why a Blazor target at all, and why it silently degrades background modes
Section titled βWhy a Blazor target at all, and why it silently degrades background modesβShiny.Locations.Blazor exists because foreground GPS in the browser is a real use case β map pickers, βfind nearbyβ queries, fitness apps running in PWAs. The implementation wraps navigator.geolocation.watchPosition.
What it doesnβt do:
- Background GPS. The browser has no API for it. The Service Worker canβt reach the Blazor runtime, and
Background Syncdoesnβt carry GPS payloads. - Geofencing. Browsers donβt expose a geofencing API at all.
Shiny.Locations.Blazorships noIGeofenceManager.
Setting GpsBackgroundMode.Standard or Realtime on a Blazor GpsRequest is silently treated as None, with a warning logged. Logging-instead-of-throwing is deliberate β cross-platform code that uses AddGps() with a Realtime request shouldnβt crash on the web build; it should just behave like foreground.
The architectural answer for region-based behaviour on the web is server-side: have the client report foreground GPS, evaluate regions on the backend, and notify the user via Web Push.
What Shiny.Locations deliberately does not do
Section titled βWhat Shiny.Locations deliberately does not doβ| Not built in | Why |
|---|---|
| Per-listener cron / interval scheduling | iOS doesnβt honour intervals on Standard mode. Use platform-specific records on Android if you need it. |
| Geofence regions persisted to remote storage | Repository is local. Sync via Shiny.Data.Sync or your own API if you need cloud-managed regions. |
| Polygon / arbitrary-shape geofences | The OS APIs are circular-only on iOS and Android. For polygons / R*Tree spatial queries, use Spatial Geofencing. |
| Indoor positioning (beacons, Wi-Fi triangulation) | Out of scope. Use Bluetooth LE scanning or Shiny.Beacons (deprecated; community fork available). |
| Route playback / mock readings | Use the OS simulator / emulator GPS tools. Mocking belongs in tests, not the library. |
| Reverse geocoding | Shiny.Maui.Controls.AddressEntry integrates with Microsoft.Maui.Devices.Sensors.Geocoding. The Locations library deals in coordinates only. |
| Geofence persistence on Blazor | No geofencing on web β see above. |
When not to use Shiny.Locations
Section titled βWhen not to use Shiny.Locationsβ- You need a one-shot location read for a form. Use the MAUI Essentials
Geolocation.GetLocationAsync()API directly β no listener, no DI, no Shiny. - You need to monitor thousands of regions. OS geofencing caps you (20 on iOS, 60 on Android). Use Spatial Geofencing with an R*Tree-indexed database; the foreground/background semantics are yours to wire.
- You need indoor positioning. Bluetooth LE proximity, beacon ranging, or Wi-Fi RTT are different libraries. Shiny GPS is meters-of-accuracy, not centimeters.
- You need exact-time geofence triggers (
"alert me at 6pm when I'm near home"). Combine with Local Notifications β geofencing tells you where, notifications tell you when. - You need server-side region evaluation. Stream foreground GPS to your backend and evaluate there. The library doesnβt replace a server-side pipeline.
If your work needs background location streams that survive suspension on iOS and Android, geofence transitions that survive process kill, and a fallback path when Google Play Services is missing β that is what this library is for.
Related
Section titled βRelatedβShiny.Spatialgeofencing β R*Tree-indexed polygon geofences for region counts above the OS limits.Shiny.Jobsarchitecture β for deferred work that a geofence transition might trigger (sync, upload, notification).- Motion Activity β pairs naturally with GPS for βstationary vs walking vs drivingβ inference.