Skip to content

Blog

Shiny Client v4 - Windows Support, .NET 10, and a Ton of Improvements

It’s been a long road, but Shiny Client v4 is here. This is a major release that brings Windows support, moves to .NET 10, and packs in a significant number of fixes and enhancements across almost every module. Let’s dig in.


The headline feature of v4 — BluetoothLE, BLE Hosting, HTTP Transfers, and Locations all now work on Windows. Background support isn’t available yet on the Windows platform, but foreground scenarios are fully supported. This opens up a whole new set of use cases for desktop and kiosk applications built with .NET MAUI.


v4 enforces net10.0 target frameworks across the board. This is a breaking change, so make sure your projects are targeting .NET 10 before upgrading.


HTTP Remote Configuration has been moved directly into Shiny.Extensions.Configuration. No more separate package — remote config is now a first-class citizen in the configuration stack.


Locations got a lot of love in this release, particularly on iOS where we’ve adopted the newer Apple APIs:

  • iOS 18+ now uses CLMonitor for GPS — the modern replacement for the legacy location APIs
  • New geofence registration mechanics for iOS 17+ using the new CLMonitor API
  • Geofence Manager RequestState now works correctly on the new CLMonitor API
  • GPS background permission on iOS is now requested immediately instead of waiting
  • GpsDelegate now has a boolean to detect if the device is stationary — useful for battery optimization and movement detection
  • GpsDelegate batch fix — the base calculations could receive a batch and trigger multiple calculations. This is now a synchronized operation
  • Android background location permission — no longer requests ACCESS_BACKGROUND_LOCATION unless realtime GPS with less than API 31 or standard background tracking is being used

HTTP Transfers received some of the most impactful enhancements in v4:

  • New transfer types — Transfers can now be UploadMultipart, UploadRaw (body is raw bytes), or Download. The UploadRaw type is critical for sending directly to services like Azure Blob Storage
  • Azure Blob Storage helperAzureBlobStorageRequest.CreateForAzureBlobStorage static helper method makes it dead simple to queue uploads to Azure Blob Storage
  • New HttpTransferDelegate — allows you to set retries and detect denied authorization, enabling you to refresh your token and issue a new request
  • Thread-safe HttpTransferMonitor — now uses a thread-safe BindingList
  • Android file validation — uploads now verify the file exists before queuing, and download directories are checked before queuing
  • iOS special character fix — filenames with special characters are now sent properly, along with improved form data upload

  • iOS raw notification data — you can now #if IOS to get an AppleNotification that contains the raw NSDictionary for full access to the push payload
  • Azure Notification Hubs now supports template registrations for more flexible push scenarios

  • ManagedScanResult now includes the full advertisement data, giving you access to native internals when you need them
  • Improved manufacturer data parsing on Android
  • BLE Delegate now reports proper status changes for adapter enabled state on Android
  • Legacy scanning disabled on newer Android versions for better scan performance
  • Multiple thread safety improvements for ManagedScan- Peripheral cleanup on Android now matches iOS behavior for consistency

  • Android fix — successful jobs that run too long often had the Android completion handler already disposed. This has been resolved.

The main thing to watch for:

  1. Update your target frameworks to net10.0
  2. If you were using the separate remote config package, switch to Shiny.Extensions.Configuration
  3. HTTP Transfer Request types have changed — review the new UploadMultipart, UploadRaw, and Download options

Check out the full release notes for every detail, and head over to our documentation to get started.

As always, feedback and contributions are welcome on GitHub.

Shiny.SqliteDocumentDb v2.0.0

NuGet package Shiny.SqliteDocumentDb NuGet package Shiny.SqliteDocumentDb.Extensions.DependencyInjection

v2.0.0 is now available. This release focuses on flexibility — you can now map document types to dedicated SQLite tables, use custom Id properties, diff objects against stored documents, batch insert collections efficiently, customize the default table name, and use the core library without any dependency injection framework.

Microsoft.Extensions.DependencyInjection support has been extracted into its own package:

Terminal window
dotnet add package Shiny.SqliteDocumentDb.Extensions.DependencyInjection

The core Shiny.SqliteDocumentDb package no longer depends on Microsoft.Extensions.DependencyInjection.Abstractions. If you use AddSqliteDocumentStore(), add the new package. If you instantiate SqliteDocumentStore directly, no changes are needed.

You can now create a store with just a connection string:

var store = new SqliteDocumentStore("Data Source=mydata.db");

The shared document table is no longer hardcoded to "documents". Set TableName on options to use any name:

var store = new SqliteDocumentStore(new DocumentStoreOptions
{
ConnectionString = "Data Source=mydata.db",
TableName = "my_documents"
});

You can now map specific document types to their own dedicated SQLite tables. Unmapped types continue to share the default table.

var store = new SqliteDocumentStore(new DocumentStoreOptions
{
ConnectionString = "Data Source=mydata.db",
TableName = "documents"
}.MapTypeToTable<User>() // auto-derives table name → "User"
.MapTypeToTable<Order>("orders") // explicit table name
);
  • MapTypeToTable<T>() — auto-derives the table name from the type using the configured TypeNameResolution
  • MapTypeToTable<T>(string tableName) — maps to an explicit table name
  • Fluent API — calls chain for concise configuration
  • Duplicate protection — mapping two types to the same table throws ArgumentException
  • AOT-safe — type names are resolved at registration time, not at runtime

Tables are lazily created on first use with the same schema (Id, TypeName, Data, CreatedAt, UpdatedAt) and composite primary key. This works seamlessly with all store operations including transactions, the fluent query builder, projections, indexes, and streaming.

Types mapped to a dedicated table can use an alternate property as the document Id instead of the default Id. The property must be Guid, int, long, or string — the same types supported for the standard Id property.

var store = new SqliteDocumentStore(new DocumentStoreOptions
{
ConnectionString = "Data Source=mydata.db"
}.MapTypeToTable<Customer>("customers", c => c.CustomerId)
.MapTypeToTable<Sensor>("sensors", s => s.DeviceKey)
);

All four MapTypeToTable overloads support this:

OverloadDescription
MapTypeToTable<T>()Auto-derive table name, default Id property
MapTypeToTable<T>(tableName)Explicit table name, default Id property
MapTypeToTable<T>(idProperty)Auto-derive table name, custom Id property
MapTypeToTable<T>(tableName, idProperty)Explicit table name, custom Id property

Auto-generation rules still apply — Guid and numeric Ids are auto-generated when default, and the value is written back to the mapped property after insert. Custom Id remapping is only available through MapTypeToTable, keeping the shared table convention simple.

var options = new DocumentStoreOptions
{
ConnectionString = "Data Source=mydata.db"
}.MapTypeToTable<User>()
.MapTypeToTable<Order>("orders")
.MapTypeToTable<Device>("devices", d => d.SerialNumber);
var store = new SqliteDocumentStore(options);
// Users are stored in the "User" table (Id property)
await store.Insert(new User { Id = "u1", Name = "Alice", Age = 25 });
// Orders are stored in the "orders" table (Id property)
await store.Insert(new Order { Id = "o1", CustomerName = "Alice", Status = "Pending" });
// Devices are stored in the "devices" table (SerialNumber property as Id)
await store.Insert(new Device { SerialNumber = "SN-001", Model = "Sensor-X" });
// Settings go to the default "documents" table
await store.Insert(new AppSettings { Id = "global", Theme = "Dark" });
// Queries, transactions, indexes — everything works per-table
var users = await store.Query<User>().Where(u => u.Age > 18).ToList();
services.AddSqliteDocumentStore(opts =>
{
opts.ConnectionString = "Data Source=mydata.db";
opts.MapTypeToTable<User>();
opts.MapTypeToTable<Order>("orders");
opts.MapTypeToTable<Device>("devices", d => d.SerialNumber);
});

Compare a modified object against the stored document and get an RFC 6902 JsonPatchDocument<T> describing the differences. Returns null if the document doesn’t exist. Powered by SystemTextJsonPatch.

var proposed = new Order
{
Id = "ord-1", CustomerName = "Alice", Status = "Delivered",
ShippingAddress = new() { City = "Seattle", State = "WA" },
Lines = [new() { ProductName = "Widget", Quantity = 10, UnitPrice = 8.99m }],
Tags = ["priority", "expedited"]
};
var patch = await store.GetDiff("ord-1", proposed);
// patch.Operations:
// Replace /status → Delivered
// Replace /shippingAddress/city → Seattle
// Replace /shippingAddress/state → WA
// Replace /lines → [...]
// Replace /tags → [...]
// Apply the patch to any instance of the same type
var current = await store.Get<Order>("ord-1");
patch!.ApplyTo(current!);

The diff is deep — nested objects produce individual property-level operations (e.g. /shippingAddress/city), while arrays and collections are replaced as a whole. Works with table-per-type, custom Id, and inside transactions.

BatchInsert inserts multiple documents in a single transaction with prepared command reuse for optimal performance. Returns the count inserted. If any document fails (e.g. duplicate Id), the entire batch is rolled back. Auto-generates IDs for Guid, int, and long Id types.

var users = Enumerable.Range(1, 1000).Select(i => new User
{
Id = $"user-{i}", Name = $"User {i}", Age = 20 + i
});
var count = await store.BatchInsert(users); // single transaction, prepared command reused
// Inside a transaction — uses the existing transaction
await store.RunInTransaction(async tx =>
{
await tx.BatchInsert(moreUsers);
await tx.Insert(singleUser);
// All committed or rolled back together
});
  1. If you use DI, add the new package:

    Terminal window
    dotnet add package Shiny.SqliteDocumentDb.Extensions.DependencyInjection

    The AddSqliteDocumentStore() API is unchanged — just a different package.

  2. If you instantiate directly, no changes required. The default behavior is identical to v1 — all documents go to a table called "documents".

  3. Table-per-type and custom Id are opt-in. Existing databases continue to work without any changes. You can incrementally adopt table mapping for specific types while keeping everything else in the shared table.

Introducing Shiny.Music — Cross-Platform Music Library Access for .NET MAUI

Here’s something that shouldn’t be hard but is: accessing the music library on a user’s device from .NET MAUI.

On Android, you need MediaStore.Audio.Media, a ContentResolver, cursor iteration, and different permission models depending on whether you’re targeting API 33+ or older. On iOS, you need MPMediaQuery, MPMediaItem, AVAudioPlayer, and an NSAppleMusicUsageDescription entry or the app crashes on launch. There’s no MAUI abstraction. No popular NuGet package. You write platform-specific code from scratch every time.

So I built Shiny.Music — a clean, DI-first API that gives you permission management, metadata queries, playback controls, and file export across both platforms.


Two interfaces, one registration call:

  • IMediaLibrary — request permissions, query all tracks, search by title/artist/album, copy track files to app storage
  • IMusicPlayer — play, pause, resume, stop, seek, with state tracking and completion events
MauiProgram.cs
builder.Services.AddShinyMusic();

That registers both interfaces as singletons. Inject them anywhere.


public class MusicPage
{
readonly IMediaLibrary library;
readonly IMusicPlayer player;
public MusicPage(IMediaLibrary library, IMusicPlayer player)
{
this.library = library;
this.player = player;
}
async Task LoadAndPlay()
{
// 1. Request permission
var status = await library.RequestPermissionAsync();
if (status != PermissionStatus.Granted)
return;
// 2. Browse the library
var allTracks = await library.GetAllTracksAsync();
// 3. Or search
var results = await library.SearchTracksAsync("Bohemian");
// 4. Play a track
await player.PlayAsync(results[0]);
// 5. Control playback
player.Pause();
player.Resume();
player.Seek(TimeSpan.FromSeconds(30));
player.Stop();
}
}

Permissions differ between platforms:

PlatformPermissionNotes
Android 13+ (API 33)READ_MEDIA_AUDIONew granular media permission
Android 12 and belowREAD_EXTERNAL_STORAGELegacy broad storage permission
iOSApple Music usage descriptionMust be in Info.plist or app crashes

The library handles this automatically. RequestPermissionAsync() prompts the user with the correct platform permission. CheckPermissionAsync() checks the current status without prompting.

Android AndroidManifest.xml:

<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

iOS Info.plist:

<key>NSAppleMusicUsageDescription</key>
<string>This app needs access to your music library to browse and play your music.</string>

Every track comes back as a MusicMetadata record:

public record MusicMetadata(
string Id, // Platform-specific unique ID
string Title,
string Artist,
string Album,
string? Genre,
TimeSpan Duration,
string? AlbumArtUri, // Android only — null on iOS
string ContentUri // content:// (Android) or ipod-library:// (iOS)
);
// Get everything
var tracks = await library.GetAllTracksAsync();
// Search across title, artist, and album
var results = await library.SearchTracksAsync("Beatles");

On Android, queries go through MediaStore.Audio.Media with ContentResolver. On iOS, they use MPMediaQuery with MPMediaPropertyPredicate.

Need to copy a track to your app’s storage?

var destination = Path.Combine(FileSystem.AppDataDirectory, "exported.m4a");
bool success = await library.CopyTrackAsync(track, destination);

Returns false if the copy isn’t possible — which brings us to the DRM caveat.


await player.PlayAsync(track); // Load and play
player.Pause(); // Pause at current position
player.Resume(); // Resume from paused position
player.Seek(TimeSpan.FromMinutes(1)); // Jump to position
player.Stop(); // Stop and release
PlaybackState state = player.State; // Stopped, Playing, or Paused
MusicMetadata? current = player.CurrentTrack;
TimeSpan position = player.Position;
TimeSpan duration = player.Duration;
player.StateChanged += (sender, args) =>
{
// React to state transitions
};
player.PlaybackCompleted += (sender, args) =>
{
// Track finished — load next?
};

On Android, playback uses Android.Media.MediaPlayer. On iOS, it uses AVAudioPlayer via AVFoundation. Both are properly managed — resources are released on stop and disposal.


This is important to know upfront.

On iOS, Apple Music subscription tracks (DRM-protected) cannot be played or copied through this API. For these tracks:

  • ContentUri will be an empty string
  • CopyTrackAsync returns false
  • PlayAsync throws InvalidOperationException

Only locally synced or purchased (non-DRM) tracks work. The metadata (title, artist, album, duration) is still available for all tracks — you just can’t access the audio data for DRM-protected ones.

On Android, all locally stored music files work without restrictions via ContentResolver.

This is an OS-level limitation, not a library limitation. iOS simply does not expose the audio asset URL for DRM-protected media items.


PlatformMinimum VersionAudio QueryPlayback Engine
AndroidAPI 24 (Android 7.0)MediaStore.Audio.MediaAndroid.Media.MediaPlayer
iOS15.0MPMediaQueryAVAudioPlayer

Both platforms require a physical device for meaningful testing — simulators and emulators don’t have music content.


Good fit:

  • Music player apps that browse the device library
  • Apps that need to search or display the user’s music collection
  • Exporting audio files for processing (transcription, analysis, sharing)
  • Any app that integrates with locally stored music

Not the best fit:

  • Streaming music from web sources (use MediaElement or a streaming library)
  • Audio recording (use Plugin.Maui.Audio or platform audio APIs)
  • Background audio playback with lock screen controls (this is foreground playback)

Terminal window
dotnet add package Shiny.Music

Full source and sample app at the GitHub repository. Documentation coming soon at shinylib.net/client/music.

Introducing Shiny.Spatial — A Dependency-Free Spatial Database and GPS Geofencing for .NET

If you’ve ever tried to do geospatial work on .NET MAUI, you know the options aren’t great. SpatiaLite requires native binaries per platform. NetTopologySuite is a full-featured geometry library — great on the server, but heavy on mobile and hostile to AOT. And the built-in platform geofencing? iOS gives you 20 circular regions. Android gives you 60. That’s it.

I needed something different — a spatial database that works everywhere .NET runs, with zero native dependencies, and a geofencing system that can monitor thousands of polygon regions from a real geographic database. So I built Shiny.Spatial.


Two NuGet packages:

  • Shiny.Spatial — a spatial database engine that stores geometry in SQLite using R*Tree virtual tables, with all spatial algorithms implemented in pure C#. No SpatiaLite, no NetTopologySuite, no native binaries.
  • Shiny.Spatial.Geofencing — a GPS-driven geofence monitor that watches spatial database tables for region entry/exit instead of registering individual fences with the OS.

The spatial database targets netstandard2.0 and net10.0, so it runs on MAUI, Blazor, console apps, servers — anywhere SQLite runs. The geofencing package targets iOS and Android via MAUI.


Every spatial query follows the same pattern:

  1. Pass 1 — R*Tree bounding box filter (SQL, O(log n)). SQLite’s R*Tree virtual table eliminates most candidates based on bounding box overlap. This runs entirely in SQL and is extremely fast.

  2. Pass 2 — C# geometry refinement. Surviving candidates are tested with exact geometric predicates — point-in-polygon (ray-casting), segment intersection (cross-product), Haversine distance — in pure C#.

This two-pass approach is the same strategy PostGIS and SpatiaLite use internally. The difference is that both passes here require zero native extensions.

// What happens under the hood for an Intersecting query:
// Pass 1: SELECT * FROM cities_rtree WHERE min_x <= @maxX AND max_x >= @minX ...
// Pass 2: SpatialPredicates.Intersects(candidate.Geometry, queryGeometry)
using var db = new SpatialDatabase("locations.db");
var table = db.CreateTable("cities", CoordinateSystem.Wgs84,
new PropertyDefinition("name", PropertyType.Text),
new PropertyDefinition("population", PropertyType.Integer)
);

Each table gets a single R*Tree virtual table with auxiliary columns for WKB-encoded geometry and user-defined properties. No separate tables, no JOINs.

All geometry classes are immutable and sealed:

  • Point, LineString, Polygon (with holes)
  • MultiPoint, MultiLineString, MultiPolygon
  • GeometryCollection

Coordinates use double X (longitude) and double Y (latitude). Polygon supports interior rings (holes) — useful for real geographic boundaries like city limits with excluded areas.

// A simple polygon — Colorado's approximate boundary
var colorado = new Polygon(new[]
{
new Coordinate(-109.05, 37.0),
new Coordinate(-109.05, 41.0),
new Coordinate(-102.05, 41.0),
new Coordinate(-102.05, 37.0),
new Coordinate(-109.05, 37.0) // closed ring
});
// Single insert
var feature = new SpatialFeature(
new Point(-104.99, 39.74),
new Dictionary<string, object?>
{
["name"] = "Denver",
["population"] = 715522
}
);
table.Insert(feature);
// Bulk insert — transaction-wrapped
table.BulkInsert(features);

table.Query() returns a fluent builder that chains spatial filters, property filters, distance ordering, and paging:

// Find all cities within 50km of Denver, ordered by distance
var nearby = table.Query()
.WithinDistance(new Coordinate(-104.99, 39.74), 50_000)
.OrderByDistance(new Coordinate(-104.99, 39.74))
.Limit(10)
.ToList();
// Find features inside a polygon
var inColorado = table.Query()
.Intersecting(coloradoBoundary)
.ToList();
// Combine spatial + property filters
var largeCities = table.Query()
.WithinDistance(center, 100_000)
.WhereProperty("population", ">", 100000)
.OrderByDistance(center)
.Limit(20)
.ToList();
// Paging
var page2 = table.Query()
.WhereProperty("state", "=", "Colorado")
.Limit(10)
.Offset(10)
.ToList();

Property filters run in SQL (Pass 1), spatial filters run as C# refinement (Pass 2), and distance ordering plus limit/offset are applied after refinement. The query builder composes them automatically.

For simple cases, skip the builder:

var feature = table.GetById(42);
var inBox = table.FindInEnvelope(envelope);
var intersecting = table.FindIntersecting(polygon);
var contained = table.FindContainedBy(polygon);
var nearby = table.FindWithinDistance(center, 10_000); // meters

All benchmarks on Apple M2, .NET 10, in-memory SQLite, 100K point features:

QueryMean
FindIntersecting (polygon)1.15 ms
FindWithinDistance183 us
FindContainedBy987 us
GetById9.4 us
Fluent: spatial + property filter1.44 ms
Fluent: distance + order + limit254 us
InsertCountMean
BulkInsert1,0009.8 ms
BulkInsert10,00093 ms
BulkInsert100,000964 ms

The pure algorithms are fast too — Haversine at 28ns, point-in-polygon (5 vertices) at 24ns, segment intersection at 3ns.

Geometry is stored as WKB (Well-Known Binary), the same binary format used by PostGIS, SpatiaLite, and every major GIS tool. The built-in WkbReader and WkbWriter handle all seven geometry types.


This is where it gets interesting for MAUI developers.

Native geofencing on mobile has hard limits:

iOSAndroid
Max regions2060
ShapeCircle onlyCircle only
PrecisionOS-determinedOS-determined
TimingOS-determinedOS-determined
Handler time~4 secondsLimited
Emulator testingWorksUnreliable

If your app needs to know when a user enters Denver, you register a circular region around Denver’s center. But Denver isn’t a circle — it’s an irregular polygon. And if your app needs to track entry/exit for 200 cities, you’re out of luck. You’d need 200 regions, but the OS caps you at 20 or 60.

Instead of registering individual fences with the OS, Shiny.Spatial.Geofencing hooks into GPS updates and queries your spatial databases in real-time:

  1. GPS reading comes in
  2. For each monitored table, query table.Query().Intersecting(point).FirstOrDefault()
  3. Compare the current feature ID with the previously stored feature ID
  4. If they differ — fire exit for the old region and enter for the new one

No OS region limits. No circular approximations. Real polygon boundaries from real geographic data.

Install the package:

Terminal window
dotnet add package Shiny.Spatial.Geofencing

Create a delegate to handle region changes:

public class MyGeofenceDelegate(
ILogger<MyGeofenceDelegate> logger,
INotificationManager notifications
) : ISpatialGeofenceDelegate
{
public async Task OnRegionChanged(SpatialRegionChange change)
{
var name = change.Region.Properties.GetValueOrDefault("name") ?? "Unknown";
var action = change.Entered ? "Entered" : "Exited";
logger.LogInformation("{Action} {Region} (table: {Table})", action, name, change.TableName);
await notifications.Send("Geofence", $"{action}: {name}");
}
}

Register in MauiProgram.cs:

builder.Services.AddSpatialGps<MyGeofenceDelegate>(cfg => cfg
.Add(dbPath, "states")
.Add(dbPath, "cities")
);

That’s it. The library now monitors both the states and cities tables. When the user crosses a state boundary, you get an exit + enter event. When they enter a city, you get a separate enter event. The two layers are tracked independently.

public interface ISpatialGeofenceManager
{
bool IsStarted { get; }
Task<AccessState> RequestAccess();
Task Start();
Task Stop();
Task<IReadOnlyList<SpatialCurrentRegion>> GetCurrent(CancellationToken cancelToken = default);
}

GetCurrent() takes a one-shot GPS reading and tells you which region the device is currently in for each monitored table — useful for initial state on app launch.

builder.Services.AddSpatialGps<MyGeofenceDelegate>(cfg =>
{
cfg.MinimumDistance = Distance.FromMeters(300); // default
cfg.MinimumTime = TimeSpan.FromMinutes(1); // default
cfg.Add(statesDbPath, "states");
cfg.Add(citiesDbPath, "cities");
});

MinimumDistance and MinimumTime control how frequently GPS readings trigger spatial queries. The defaults balance battery life with responsiveness.

Because each table is tracked independently, you get precise layer-specific events:

  • Drive from Denver suburbs into Denver city limits → Entered: Denver (cities table)
  • Drive from Denver to Colorado Springs → Exited: Denver, Entered: Colorado Springs (cities table) — no state change event
  • Drive from Colorado into Kansas → Exited: Colorado, Entered: Kansas (states table) — plus whatever city events apply

The repo ships with ready-to-use databases:

DatabaseTableGeometryRecords
us-states.dbstatesPolygon51
us-cities.dbcitiesPoint100
ca-provinces.dbprovincesPolygon13
ca-cities.dbcitiesPoint50

Include them as MAUI assets and copy to app data on first launch. Or build your own from any GeoJSON source using the included DatabaseSeeder tool.


ConcernSpatiaLiteNetTopologySuiteShiny.Spatial
Native binariesYes — per platformNoNo
AOT compatiblePlatform-dependentReflection-heavyYes
TrimmableN/ANoYes
Bundle sizeLarge (native libs)ModerateSmall (single dep: Microsoft.Data.Sqlite)
Geometry algorithmsC/C++C#C#
R*Tree indexingBuilt-inSeparateBuilt-in (SQLite)
MAUI-readyRequires native binding per platformWorks but AOT issuesWorks everywhere

The tradeoff is coverage. SpatiaLite and NTS support hundreds of spatial operations — buffer, union, difference, convex hull, spatial joins. Shiny.Spatial covers the operations that matter for mobile apps: intersects, contains, within-distance, nearest-to. If you need computational geometry operations, use NTS on the server and ship the results as a spatial database to the device.


Good fit:

  • Geofencing beyond OS limits (more than 20/60 regions, polygon shapes)
  • Location-aware apps that need “which city/state/zone am I in?”
  • Spatial search (find nearby points of interest, stores, landmarks)
  • Offline spatial queries without a server round-trip
  • Shipping pre-built geographic databases with your app

Not the best fit:

  • Simple circular geofencing with a few regions (use Shiny.Locations IGeofenceManager instead)
  • Heavy computational geometry (buffer, union, difference) — use NTS on the server
  • Real-time map rendering — this is a query engine, not a rendering engine

Terminal window
dotnet add package Shiny.Spatial
dotnet add package Shiny.Spatial.Geofencing # for MAUI GPS geofencing

Full documentation at shinylib.net/spatial and the GitHub repository.