Skip to content
Introducing AI Conversations: Natural Language Interaction for Your Apps! Learn More

Blog

AI-Powered Navigation in Shiny MAUI Shell

What if your app could understand “My furnace is broken — it’s urgent!” and automatically open the right form with the description filled in and the priority set to Urgent? That’s exactly what the new AI integration in Shiny MAUI Shell does.

Mobile apps have dozens of pages. Users have to know where things are, tap through menus, and manually fill in fields. But with AI chat becoming the norm, we asked: what if the AI could navigate your app for you?

Shiny Shell’s source generator already knows every route in your app and every parameter each page accepts. We just needed to make that metadata available to an AI model — and give it a way to act on what it discovers.

Instead of registering a separate AI tool for every page (which doesn’t scale), we generate just two:

  1. GetAiToolApplicableGeneratedRoutes() — returns all routes that have intent descriptions and parameters. The AI calls this to discover what pages exist and what they do.
  2. NavigateToRoute() — accepts a route name and a Dictionary<string, string> of parameters. The AI calls this to navigate and pre-fill the form.

That’s it. Add a new page with [ShellMap] descriptions and [ShellProperty] inference hints, and the AI automatically discovers it. No tool registration changes needed.

The key insight is that descriptions should express user intent, not page names:

// Good — the AI matches "my pipe burst" to this route
[ShellMap<WorkOrderPage>(description: "Use when the user reports something broken,
malfunctioning, needing repair, maintenance, or service")]
// Bad — the AI has to guess what "Work order page" means
[ShellMap<WorkOrderPage>(description: "Work order page")]

Similarly, property descriptions tell the AI how to infer values from natural language. Properties can use real types — enums, ints, bools — and the generator handles conversion automatically:

public enum WorkOrderPriority { Low, Medium, High, Urgent }
[ShellProperty("Summarize what is broken based on what the user said", required: true)]
public string Description { get; set; } = string.Empty;
[ShellProperty("Infer urgency from the user's tone. Must be: Low, Medium, High, or Urgent", required: true)]
public WorkOrderPriority Priority { get; set; } = WorkOrderPriority.Medium;

The AI sends "Urgent" as a string, and the generated NavigateToRoute converts it to WorkOrderPriority.Urgent via case-insensitive Enum.Parse. The same works for int, bool, double, DateTime, Guid, and other common types.

Note that AI-compatible ViewModels do not need to implement IQueryAttributable. The generated NavigateToRoute sets [ShellProperty] properties directly on the ViewModel instance — no query attribute plumbing required.

The source generator produces GeneratedRouteInfo metadata with full parameter schemas:

public record GeneratedRouteInfo(
string Route,
string Description,
GeneratedRouteParameter[] Parameters
);
public record GeneratedRouteParameter(
string ParameterName,
string Description,
string TypeName,
bool IsRequired
);

The AI model sees the route descriptions, parameter names, types, requirements, and inference hints — everything it needs to match intent and extract values.

AI extensions are now enabled by default — just install Microsoft.Extensions.AI:

Terminal window
dotnet add package Microsoft.Extensions.AI

Register the generated AiMauiShellTools class via the AddAiTools() extension:

builder.UseShinyShell(x => x
.AddGeneratedMaps()
.AddAiTools() // registers AiMauiShellTools as singleton
);

Then inject AiMauiShellTools wherever you need AI-powered navigation. It provides a Prompt property (pre-formatted route descriptions for seeding system messages) and a Tools property (ready-to-use AITool[]):

public class ChatViewModel(AiMauiShellTools aiTools)
{
// Seed the system prompt
history.Add(new ChatMessage(ChatRole.System, aiTools.Prompt));
// Use the tools
var options = new ChatOptions { Tools = [.. aiTools.Tools] };
}

The class name is customizable via the ShinyMauiShell_AiToolsClassName MSBuild property if AiMauiShellTools doesn’t fit your naming conventions.

The sample app includes a full working demo with GitHub Copilot authentication. Users authenticate with their own GitHub account through the OAuth device flow, and the app uses the Copilot API as the chat backend. Try saying things like:

  • “My furnace is not working! URGENT” — opens the work order form with description and priority filled in
  • “I’d like to discuss a partnership. My name is Allan, email allan@test.com — opens the contact form with fields populated

Check out the AI Integration documentation for the full setup guide, or browse the sample code on GitHub.

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.