Skip to content
Shiny.NET
Shiny MAUI Shell v7 - App Links, App Shortcuts, & Navigation Interception!Shortcut me to it

Shiny Client Release Notes

Every package in the Shiny client repository ships together under one version number, so they share one set of release notes. Each release is broken down by component: Core & Hosting, BluetoothLE, BluetoothLE Hosting, Beacons, Locations, Network Discovery, Wi-Fi, Screen Recording, Contact Store, Calendar Store, Jobs, Local Notifications, Push Notifications, Live Activities, HTTP Transfers, Data Sync, Configuration.

FixBlazor
A custom IConnectivity or IBattery registered before AddConnectivity() / AddBattery() from Shiny.Core.Blazor - or before AddBlazorHttpTransfers / AddBlazorDataSync, which call them - was kept, but the browser ConnectivityManager / BatteryManager was still registered behind it, and UseShinyCore() started its JavaScript monitor for nothing. Neither is registered now when you’ve already supplied your own implementation.
Feature
Messages longer than one GATT operation. A write or notification carries at most Mtu bytes - 20 on a link that never negotiated more - so a JSON command or a certificate had to be chunked by a protocol of your own. peripheral.WriteCharacteristicMessageAsync(serviceUuid, characteristicUuid, message) splits a message to the MTU and writes the fragments in order, serialising concurrent callers on the same characteristic, and peripheral.NotifyCharacteristicMessages(serviceUuid, characteristicUuid) emits one byte[] per complete message. Each fragment carries a one-byte header (START, END and a 6-bit sequence number), so a short message costs one byte and a dropped or reordered fragment discards the message instead of emitting garbage; messages over maxMessageBytes (64 KB by default) are discarded too. The peripheral must speak the same format - [RequestResponseCharacteristic(Framed = true)] or NotifyMessage in Shiny.BluetoothLE.Hosting. BleMessageFraming and BleMessageReassembler are public for framing over anything else.
Feature
Ticketed L2CAP channels. peripheral.OpenL2CapTicketChannel(psm, token) opens a channel to a host running L2CapTicketBroker, presents the single-use token the host issued, and returns the channel as a stream once the host accepts it. A refusal throws L2CapTicketException with a Status - UnknownTicket for an expired ticket, AlreadyClaimed, VersionMismatch, Malformed or Busy - and the channel has already been closed. channel.ClaimTicket(token) runs the same handshake on a channel you opened yourself. secure defaults to true, matching the broker.
Feature
channel.AsStream() wraps an open L2CapChannel as a System.IO.Stream (L2CapChannelStream), so a channel can be handed to CopyToAsync, a serializer, or a hash. Reads share the buffered reader the file transfer helpers use, so a stream and UploadFile / DownloadFile can take turns on one channel; writes over MaxWriteSize (4096 by default) are split. It is asynchronous only - the synchronous Read and Write throw - and disposing it closes the channel unless you pass leaveOpen: true.
Feature
Framed request/response characteristics. [RequestResponseCharacteristic(Framed = true)] accepts requests and sends replies longer than one GATT operation. The generator treats each write as one fragment, reassembles per central on that central’s context so two centrals never interleave, answers intermediate fragments Success without running the handler, runs the handler once with the whole message, and splits the reply to the writing central’s MTU. A malformed, out-of-sequence or oversized message - MaxMessageBytes, 64 KB by default - is discarded, answered Failure, and reported to OnBleHandlerError. For hand-written handlers, IGattCharacteristic.NotifyMessage(message, central) sends a framed notification and BleServiceContext.GetMessageReassembler(characteristicUuid) hands back the per-central reassembler. The central side is WriteCharacteristicMessageAsync / NotifyCharacteristicMessages in Shiny.BluetoothLE.
Feature
L2CapTicketBroker shares one L2CAP PSM between every bulk transfer. Opening a PSM per transfer invites a stale number - PSMs come from a small dynamic range and a released one can be handed straight to another process before the central connects. The broker (services.AddL2CapTicketBroker()) opens one listener on first use and keeps it. Reserve(label, lifetime, handler) returns an L2CapTicket whose Psm and Token you send to the central over an authenticated route; the first channel to present the token gets the handler, with the channel as a stream that is closed when the handler returns. A wrong, expired or already-claimed token, or a channel that stays silent past HandshakeTimeout, is answered and closed without reaching any handler. Release(token) withdraws a ticket and cancels a transfer already running. Listeners are Secure by default.
Feature
channel.AsStream() wraps an accepted L2CapChannel as a System.IO.Stream (L2CapChannelStream) - the same type a broker handler receives. Create it as soon as the channel opens, since it subscribes to DataReceived on construction; it is asynchronous only, splits writes over MaxWriteSize, and closes the channel when disposed unless you pass leaveOpen: true.
FixApple
IMdnsManager.Publish could hang forever. Nothing referenced the NSNetService between starting the publish and Bonjour’s reply, so a garbage collection in that window - likely during app startup - collected it: the service was never advertised and the returned task never completed. The service is now held until Bonjour reports success or failure. Cancelling a publish still in flight now also stops the service, where before it only stopped waiting and could leave the service advertised with nothing to take it down.
BREAKINGFixLinux
services.AddWifi() and AddWifiHotspot() did not compile with Shiny.Net.Wifi.Linux. The Linux package targets plain net10.0 and declares both methods, but the base Shiny.Net.Wifi package it depends on declared them on its own net10.0 target too, registering the stub - so every call was ambiguous (CS0121) and the documented setup only built by naming LinuxWifiServiceCollectionExtensions explicitly. The base package now declares them only on its platform targets, as Shiny.BluetoothLE does, and services.AddWifi() resolves to the NetworkManager implementation. Breaking for plain .NET on Windows or macOS: the base package no longer registers the NetWifiManager / NetWifiHotspot stub there; register them yourself if you relied on them.
FixLinux
Repeating notifications (RepeatInterval) now fire. The in-process scheduler recalculated the next alarm on every tick, and that calculation always lands in the future - so a repeating notification never came due, and it compared that UTC value against local time as well. The next fire time is now stored when you call Send and advanced after each delivery. IntervalTrigger.TimeOfDay is treated as local time and fires today if that time is still ahead, rather than always waiting until tomorrow. Cancel also clears the stored schedule.
FixiOS
A write without response could deadlock every operation on the peripheral. When CoreBluetooth’s send buffer was full, WriteCharacteristic(..., withResponse: false) checked CanSendWriteWithoutResponse and only then subscribed to the peripheralIsReadyToSendWriteWithoutResponse callback. That callback fires once, on CoreBluetooth’s own queue, so if it landed in the gap between the check and the subscription it went to nobody: the write never went out and the operation never completed. Because it was holding the peripheral’s operation lock, every later read, write, discovery or notification hook on that peripheral queued behind it until the link dropped. The wait now subscribes to the callback first and re-checks the buffer afterwards, so the ready signal cannot be missed. Applies to iOS, tvOS, Mac Catalyst and macOS - Platforms/MacOS is a separate compiled tree and carried the same bug.
FixiOS
IGattCharacteristic.Notify could hang forever, or silently drop the value, when the transmit queue was full. UpdateValue returns false when CoreBluetooth’s queue is full, and Notify only attached its ReadyToUpdateSubscribers handler after that call returned - so a ready callback that arrived in between was missed and the returned task never completed. When the callback was caught, the retry ignored UpdateValue’s result, so a queue that had filled again dropped the notification while Notify still reported success. The handler is now attached before every attempt, and Notify retries on each ready callback until CoreBluetooth accepts the value - the task completes only once the notification is actually queued. Applies to iOS, Mac Catalyst and macOS.
Enhancement
IGattCharacteristic.Notify(data, cancellationToken, params centrals) - a new overload that stops waiting when the token is cancelled. It matters on iOS, Mac Catalyst and macOS, where Notify waits while CoreBluetooth’s transmit queue is full; that wait now also ends with an InvalidOperationException if Bluetooth stops being powered on, since a powered-off manager never drains the queue and the task would otherwise never complete. Generated [BleService] classes get a matching NotifyX(data, cancellationToken, params centrals), and the reply a [RequestResponseCharacteristic] sends now passes BleHostToken, so tearing the service down abandons it. The existing Notify(data, params centrals) is unchanged.
FixiOS
Notify(data, centrals) ignored the centrals you named and sent to every subscriber - UpdateValue was always called with a null central list. The named centrals are now passed through, so a targeted notification (including every generated request/response reply) reaches only the central it was meant for. Applies to iOS, Mac Catalyst and macOS.
FixAndroid
Notify(data) with no centrals sent to nobody. The fallback to “every subscriber” was written as centrals.OfType<Peripheral>() ?? SubscribedCentrals, and OfType never returns null, so the empty list was used as-is. An empty list now means every subscriber, as documented.
Fix
SubscribedCentrals stayed empty unless SetNotification was given a subscribe hook, because subscriptions were only tracked when there was a hook to call. The source generator registers class-level [NotifyCharacteristic] and every [RequestResponseCharacteristic] without one, so XSubscribers / HasXSubscribers always read empty and every generated request/response reply was dropped through OnBleResponseDropped. On Android it was worse: the CCCD descriptor was only added when a hook existed, so a central could not subscribe to such a characteristic at all. Subscriptions are now tracked, and the descriptor added, whenever the characteristic can notify or indicate. Applies to iOS, Mac Catalyst, macOS, Android and Windows; Linux already tracked them.
BREAKINGFixAndroid
IPeripheral.Uuid for a connected central was the literal string System.Byte[] - the identifier was built with byte[].ToString() - so every central had the same id and none could be told apart. It is now the same MAC-derived GUID Shiny.BluetoothLE uses for the central role. This is a behaviour change: anything that stored or compared the old value will see a different, per-device identifier.
FixAndroid
Back-to-back notifications were silently dropped. Android allows one notification in flight per central and refuses the next until onNotificationSent arrives, but Notify sent without waiting and ignored the result - so a burst lost everything after the first while every call reported success. Notify now waits for onNotificationSent before sending that central its next value (different centrals are sent in parallel), throws if Android refuses a notification or reports a failed status, and stops waiting when the central disconnects, the GATT server closes, or the token is cancelled. On API 33+ it uses the notifyCharacteristicChanged overload that carries the value rather than writing it onto the shared characteristic first. Indications were never confirmed either: confirm was always false, so a central that enabled indications received plain notifications. It now gets indications.
FeatureLinux
The Linux GATT server is implemented. AddService threw NotSupportedException - the BlueZ plumbing behind it was a TODO - so nothing could be read, written or subscribed to, and Notify always threw “not registered with BlueZ yet”. Shiny now exports the application BlueZ expects - an org.freedesktop.DBus.ObjectManager root with an org.bluez.GattService1 object per service and an org.bluez.GattCharacteristic1 object per characteristic - and registers it with GattManager1. ReadValue and WriteValue reach your SetRead / SetWrite handlers with the requesting central, offset and MTU; WriteRequest.Respond sends its status back as the GATT response immediately, and a write without response (type=command) reports IsReplyNeeded = false. StartNotify / StopNotify drive SubscribedCentrals and your subscribe hook, and Notify sends the value as PropertiesChanged on Value, which BlueZ turns into a notification or indication. BlueZ reads the object tree only once, at registration, so adding or removing a service re-registers the whole application. Two BlueZ limits: it tells an external application only whether notifications are enabled, not which central enabled them, so while any central is subscribed every connected central is reported as subscribed; and it sends each value to every subscribed central, so Notify(data, centrals) cannot narrow the recipients - a targeted notification is skipped when none of the named centrals is subscribed and otherwise reaches every subscriber, [RequestResponseCharacteristic] replies included.
FixBlazor
IConnectivity and IBattery now report real values in the browser. Shiny.Core.Blazor’s managers imported their JS module but never called its init(), so navigator.getBattery() was never resolved and navigator.connection was never read - battery reported “not charging” at 100% forever and the connection type was always Unknown. Nothing called StartAsync() either, so the module was never even imported unless an app knew to reach for the concrete type and start it. Both managers now initialize the module as part of starting, and start themselves the first time a property is read or Changed is subscribed to, reporting Unknown only until that completes. await host.Services.UseShinyCore() starts them up front when the first read needs to be accurate. Where a browser has no Battery Status API (Firefox, Safari), Status now stays Unknown instead of claiming the device is discharging.
FixiOS
ShinyAppDelegate no longer crashes the app on launch. FinishedLaunching ended with return base.FinishedLaunching(...), but didFinishLaunchingWithOptions is an optional protocol member, so the binding’s base implementation throws Foundation.You_Should_Not_Call_base_In_This_Method - the host was built and run, then the exception took the process down before the first frame. It returns true directly now. Affects every app hosting through Shiny.Hosting.Native on iOS, tvOS and Mac Catalyst; MAUI hosting (UseShiny()) was never on this path.
FeaturetvOS
tvOS support. Shiny.Core and Shiny.Hosting.Native now ship net10.0-tvos targets. tvOS reuses the iOS platform layer wholesale - IosPlatform, IosLifecycleExecutor, ShinyAppDelegate and the IIosLifecycle hooks are all the same types - so a tvOS head boots Shiny exactly the way an iOS one does. Two deliberate differences: IBattery reports a permanently full battery, because an Apple TV is mains powered and UIDevice carries no battery API on tvOS; and IIosLifecycle.INotificationHandler does not exist on tvOS, because notifications there can only change the app icon badge and there is no UNNotificationResponse to hand back. Modules with tvOS targets: Shiny.BluetoothLE, Shiny.Net.Discovery, Shiny.Jobs, Shiny.Net.Http, Shiny.Push, Shiny.ScreenRecorder and Shiny.Data.Sync.
FeatureBlazor
Web Bluetooth advertisement payloads reach the app at last. IAdvertisementData.ManufacturerData and .ServiceData both returned null on Blazor - the JS layer read neither off the advertisementreceived event, so anything that lives in a payload rather than in the device name or service UUID list was invisible. Both are now marshalled across (Web Bluetooth reports them as Maps, which do not survive JSON, so they are flattened to arrays with base64 payloads). This is what makes Shiny.Beacons work in the browser. The chooser fallback still carries no payload - requestDevice reports none - so this only applies where navigator.bluetooth.requestLEScan is available.
FixiOS
A scan started before CoreBluetooth finished powering on found nothing, forever. Scan() called ScanForPeripherals the moment it was subscribed, and IBleManager.Manager builds the CBCentralManager lazily - on the first call that is the same statement, so the central was still reporting Unknown. CoreBluetooth drops commands issued below PoweredOn without calling back and without an error (it logs API MISUSE to the native console and nothing reaches your ILogger), so no native scan ever started: DiscoveredPeripheral was never called, the observable never emitted, and IsScanning returned true the whole time. A retry a moment later worked, which made it look as though only peripherals already advertising when the scan began were discoverable. The scan request is now parked exactly as a connect below PoweredOn already was (#1652) and issued as soon as the central reports powered on, so Scan() no longer requires you to await RequestAccess() first. A scan also survives the user cycling Bluetooth: CoreBluetooth tears it down on power-off, and Shiny re-issues it on power-on rather than leaving a live subscription attached to a dead scan. IsScanning now tracks the native scan and reads false while a request is parked. Applies to iOS, tvOS, Mac Catalyst and macOS - Platforms/MacOS is a separate compiled tree and carried the same bug. Android and Windows were unaffected: Android reads adapter state synchronously, and Windows still gated the scan on RequestAccess(). Regression introduced in 5.0.
FixiOS
The first advertisement of a scan is no longer dropped. Scan() subscribed to the internal scan-result subject after calling ScanForPeripherals, and CoreBluetooth can deliver a cached advertisement synchronously from that call - so the earliest result went to nobody. The subscription is now in place before the native scan is issued. Applies to iOS, tvOS, Mac Catalyst and macOS.
Fix
IBleManager.StopScan() now clears IsScanning. Only the scan subscription’s disposal cleared it, so stopping a scan through the manager - which the API docs recommend when the original subscription is out of reach - left the flag set and every later Scan() threw There is already an existing scan for the life of the process. Applies to iOS, tvOS, Mac Catalyst, macOS and Android; Windows already did this.
FeaturetvOS
tvOS support. Shiny.BluetoothLE now ships a net10.0-tvos target. CoreBluetooth’s central role is complete on tvOS - scanning, connecting, GATT, and L2CAP channels all work through the same IBleManager API as iOS, with no tvOS-specific code on your side. Two things differ from iOS and neither is Shiny’s to fix: tvOS has no bluetooth-central background mode, so scans and connections end when the app is suspended and AppleBleConfiguration.RestoreIdentifier buys you nothing there; and the peripheral role is absent entirely, so Shiny.BluetoothLE.Hosting has no tvOS target - CBMutableService and CBMutableCharacteristic carry no constructors on tvOS, which is Apple stating that an Apple TV cannot be a GATT server.
BREAKINGFix
Turning Bluetooth off and on is now propagated to connected peripherals. Neither platform told the peripheral anything about an adapter power cycle - the event reached IBleDelegate.OnAdapterStateChanged and stopped there - and neither OS reports the resulting drop per peripheral (CoreBluetooth does not call didDisconnectPeripheral on power-off, and several Android devices deliver no OnConnectionStateChange either). So WhenStatusChanged() stayed on Connected for a link that no longer existed, contradicting IPeripheral.Status, which reads the platform live and already said Disconnected; notifiers were never cleared, queued operations were never broken (the deadlock family of #1637), and AutoConnect - which is edge-triggered on a Disconnected emission - never fired, so the link never came back. An adapter that drops below available now runs the same teardown a real disconnect gets on iOS, Mac Catalyst, macOS and Android, and an adapter that returns re-issues the connect for every peripheral connected with AutoConnect: true. Connect calls made while the adapter is down - Shiny’s own reconnect, or your Connect() from OnAdapterStateChanged - are parked rather than issued, because ConnectPeripheral below PoweredOn and ConnectGatt with the adapter off are silent no-ops that never report back; they replay when the adapter returns. CancelConnection() stays final and discards anything parked, and starting a scan no longer evicts a peripheral that is waiting to reconnect - the manager’s adapter hooks only reach peripherals still in its cache, so a Scan() while the link was down used to silently cancel the pending reconnect. Not applicable to Linux (BlueZ), where the adapter’s Powered state is not tracked, or to Blazor, where Web Bluetooth does not expose adapter state at all; Windows needs none of it, since ConnectionStatusChanged fires on a radio power-down by itself. This is a behaviour change: consumers now receive a Disconnected on WhenStatusChanged() / WhenDisconnected() when the user turns Bluetooth off. If you drive your own reconnect off that stream, gate it on the adapter being available - AutoConnect: true already handles the cycle for you.
FixiOS
A Connected that Apple actually delivered is no longer swallowed when the status stream is stale. ReceiveStateChange only emitted when the new state differed from the subject’s current value, so after a link died without a didDisconnectPeripheral - exactly what an adapter power cycle produces - a subsequent didConnectPeripheral was dropped: the link was up, WhenConnected() never fired, and the managed notification re-hook that hangs off that transition never rebuilt the subscription, leaving a connected peripheral that delivered nothing. CoreBluetooth only calls didConnectPeripheral for a brand new link, so that combination now emits the Disconnected the platform never sent followed by the Connected it did, repairing the stream instead of discarding the event. Applies to iOS, Mac Catalyst, and macOS.
FixmacOS
The macOS target framework now shares the connection lifecycle the other Apple platforms have. Platforms/MacOS is a separate compiled tree from Platforms/Apple, and it had been left behind: auto-reconnect used a skipFirst flag on a stream seeded from the live peripheral state, so the first real disconnect was swallowed whenever the peripheral was already connected at the time Connect() was called; there was no retry on FailedToConnectPeripheral, so a cold-start failure never retried; the previous pending connection slot was never cancelled before re-issuing; and WhenConnectionFailed() did not replay, so a subscriber that hooked up straight after Connect() missed the failure. macOS now behaves as iOS and Mac Catalyst do, including the adapter power-cycle handling above.
FixAndroid
Auto-reconnect now also retries from WhenConnectionFailed(), as Apple has always done. A ConnectGatt that fails outright - a null client, or the GATT binder not yet being up in the instant ACTION_STATE_CHANGED/STATE_ON arrives - reports on the connection-failure stream and emits nothing on the status stream, and the reconnect subscription only listened to the latter. Since the adapter-on replay is the one thing that re-issues a parked connect, a single failure there left the peripheral disconnected for the life of the process even with AutoConnect: true. Retries are throttled to one per second and skip an attempt while a GATT client is already open.
FixiOS
Connect(new ConnectionConfig(AutoConnect: false)) now disposes an auto-reconnect armed by an earlier Connect(). The disposal sat inside the AutoConnect: true branch, so downgrading a peripheral to manual reconnection left the old subscription live and the peripheral kept reconnecting on a config that no longer asked for it - including across an adapter power cycle. Android already did this. Applies to iOS, Mac Catalyst, and macOS.
FixAndroid
DoConnect now resets RequiresServiceDiscovery whenever it opens a new BluetoothGatt client. The flag was only cleared by the two teardown paths (CancelConnection and a reported disconnect), so a Connect() on a peripheral whose previous client was never reported disconnected - the adapter power cycle again - arrived with the flag still false. BluetoothGatt.getServices() on a fresh client returns an empty but non-null list, so the cache check short-circuited, discovery was never issued, and every service, characteristic, and notify lookup threw No service found with uuid: ... for the life of the connection. A new client has by definition discovered nothing, so the invariant now lives where the client is created.
FixAndroid
ConnectionConfig.AutoConnect now actually reconnects a dropped link on Android. ConnectGatt’s autoConnect flag only keeps the platform’s background reconnect pending while the GATT client stays open, and OnConnectionStateChange has to close that client on every disconnect - closing it is what releases Android’s 7-client limit and terminates in-flight operations. Nothing re-opened it, so a peripheral that was powered off or carried out of range stayed Disconnected indefinitely even though the option is documented as enabling automatic reconnection. Connect(new ConnectionConfig(AutoConnect: true)) now arms a subscription that re-issues ConnectGatt when the link drops - the same shape Apple has always had - so the platform’s own background reconnect is restored rather than replaced by a retry loop: one ConnectGatt is issued and Android holds it pending until the peripheral returns. Reconnect attempts are throttled to one per second so a peripheral that rejects the reconnect cannot produce a status 133 connect/disconnect storm, an explicit CancelConnection() tears the subscription down before it can undo the cancel, and a Disconnected emitted while a fresh connect is already in flight is ignored. Fixes #1647.
FeatureLinux
BlueZ advertising is implemented. StartAdvertising and AdvertiseBeacon both threw NotSupportedException on Linux - the D-Bus plumbing behind them was a TODO. BlueZ inverts the model the other platforms use: rather than handing a payload to an API, the application exports an org.bluez.LEAdvertisement1 object and registers its path with LEAdvertisingManager1, and BlueZ then calls back into the process to read the properties. Shiny now exports that object - answering Properties.GetAll/Get, and Introspect so busctl can read it back - and registers it, mapping AdvertisementOptions onto BlueZ’s Type, LocalName, ServiceUUIDs, ServiceData, ManufacturerData and Includes. Two details that make it behave rather than merely work: Discoverable is omitted entirely on a broadcast-type advertisement, which BlueZ rejects the whole registration over, and every property that has no value is omitted rather than sent empty, which it also rejects. BlueZ’s Release() is honoured, so an adapter power-down, a bluetoothd restart, or another client taking the last advertising slot clears IsAdvertising instead of leaving it stuck on. Each registration takes a fresh object path, so a Stop immediately followed by a Start cannot collide with the previous registration BlueZ has not finished dropping. Note the consequence of the callback model: the process must stay alive and on the system bus for the advertisement to keep running.
BREAKINGFix
AdvertiseBeacon broadcast a byte-swapped UUID, major and minor - no receiver could match the beacon. Every multi-byte field in an iBeacon payload is big-endian, but the packet was assembled from Guid.ToByteArray() and BitConverter.GetBytes(), both of which are little-endian on every platform Shiny targets. The UUID’s first three fields and both the major and minor therefore went out reversed. Android additionally prefixed 0xBE 0xAC - AltBeacon’s identifier - where iBeacon requires 0x02 0x15, so the payload was not an iBeacon at all, and Mac Catalyst/macOS wrote the same reversed bytes into kCBAdvDataAppleBeaconKey. All three now build the packet through IBeaconPacket, which is big-endian throughout and covered by round-trip tests against a known-good advertisement. This is a behaviour change: a receiver that was matching the previously-broadcast (reversed) UUID will stop matching, because the device now advertises the UUID you actually asked for.
FeatureWindows
Windows can broadcast iBeacon. AdvertiseBeacon threw NotSupportedException on Windows, but WinRT has no problem with it - an iBeacon is a manufacturer data section, and BluetoothLEAdvertisementPublisher takes those. It now works the same as everywhere else.
Feature
AdvertisementOptions can carry a real payload. New ServiceData, ManufacturerData, IsConnectable and IncludeTxPower properties, so a peripheral can advertise arbitrary data rather than only a local name and service UUIDs - which is what beacon formats, and most proprietary discovery schemes, actually need. Supported on Android, Windows and Linux. Apple’s startAdvertising reads only the local name and service UUID keys and silently discards anything else in the dictionary, so setting either payload property throws there with a message naming the limit, rather than advertising something that never goes out.
Chore
AdvertisementServiceData and ManufacturerData moved from Shiny.BluetoothLE to Shiny.BluetoothLE.Common, so the peripheral role can describe the payloads it advertises without depending on the central role. Same namespace, so nothing recompiles, and type forwards keep already-compiled assemblies resolving.
Feature
IBeaconPacket (in Shiny.BluetoothLE.Common, so both roles can reach it) reads and writes the 23-byte iBeacon manufacturer payload - Build(uuid, major, minor, txPower), Read(span) and IsIBeacon(companyId, span). Use it rather than hand-rolling the bytes; the endianness trap above is easy to fall into and silent when you do.
FixiOS
AddService(...) and StartAdvertising(...) hung when called before the peripheral manager powered on. A CBPeripheralManager reports Unknown until its first state callback lands, and CoreBluetooth drops addService / startAdvertising issued below PoweredOn without ever invoking the completion delegate. Both methods await exactly that delegate, so a call made at startup - registering [BleService] classes from a hosting builder, say - never completed and never faulted. Both now wait out the power-on handshake first and throw when the adapter turns out to be unusable, instead of awaiting a callback the OS will not send. This does not prompt for anything; you still call RequestAccess() yourself to surface permission state to the user. Applies to iOS, Mac Catalyst and macOS. Regression introduced in 5.0.
Feature
Shiny.Beacons is back, and now covers every platform. iBeacon ranging, background region monitoring, and broadcasting across iOS, Mac Catalyst, macOS, Android, Windows, Linux and Blazor WebAssembly, with Eddystone (UID, URL and TLM) added as a first-class citizen alongside it. AddBeaconRanging(), AddBeaconMonitoring<TDelegate>(), AddEddystoneScanning() and AddBeaconBroadcasting() register the four capabilities independently, so an app that only wants to read a beacon does not pay for the foreground service or the always-on location prompt that monitoring needs. There is no tvOS target: tvOS binds no CLBeacon type of any kind, so iBeacon is impossible there and an Eddystone-only surface that threw for half its members was not worth shipping.
Feature
Eddystone - UID, URL and TLM. IEddystoneScanner.WhenFrameReceived() emits typed frames: EddystoneUidFrame (10-byte namespace + 6-byte instance), EddystoneUrlFrame (with the full scheme and top-level-domain compression table, so https://www.example.com/ costs 9 bytes on the air), and EddystoneTlmFrame (battery volts, temperature, advertisement count, uptime). The spec’s two sentinel values are honoured rather than passed through: 0 millivolts means the beacon is mains powered and 0x8000 means no temperature sensor is fitted, both surfaced as null instead of as a flat battery and a 128°C beacon. Encrypted TLM is handed back intact for a caller holding the identity key, and EID frames surface their raw rotating identifier - resolving one needs the Curve25519/AES-EAX derivation from the EID spec, which is not implemented. Because Eddystone rides in service data under 0xFEAA rather than manufacturer data, it works identically on every platform including Apple’s, and filtering the scan on that UUID is also what lets it keep delivering while an iOS app is backgrounded.
Feature
Distance estimates are filtered, and the estimator is pluggable. BLE RSSI swings 10 dBm or more between consecutive advertisements from a stationary beacon, which a per-packet distance calculation turns into metres of jitter - and per-packet is exactly what the pre-5.0 module did. Every sample now goes through RssiFilter, a windowed trimmed mean (20 seconds, top and bottom tenth discarded) before it reaches an IBeaconDistanceEstimator. Two ship: PathLossDistanceEstimator (d = 10^((txPower-rssi)/(10n)), the default, with n configurable from free-space 2.0 up towards 3-4 for cluttered indoor spaces) and CurveFitDistanceEstimator (the Radius Networks/AltBeacon empirical fit, for parity with other Android beacon stacks). Window, trim fraction, default calibration power, proximity thresholds and region exit timeout are all on BeaconRangingOptions. On Apple platforms CoreLocation does its own smoothing and hands back a distance directly, so the filter and estimator are deliberately bypassed there and only the thresholds apply.
Fix
Proximity was computed from a formula that was not a distance in any unit. CalculateProximity evaluated Math.Pow(10, (txpower - rssi) / 20) and compared the result against 6E-6 and 0.5E-6, so in practice every beacon at every distance came back Immediate. Proximity is now derived from the estimated distance against Apple’s own boundaries - under 0.5 m immediate, under 3 m near, beyond that far - and both thresholds are configurable.
Fix
A beacon region entered from cold never raised an entry event. The Android monitoring task seeded the first sighting with state.IsInRange ??= true and then tested if (!state.IsInRange.Value) before firing, so the very transition monitoring exists to report was swallowed on every first encounter. Only a beacon that had already been seen, gone out of range, and come back could produce an Entered. The state machine is now a separate testable type (BeaconRegionMonitor) with no Bluetooth dependency, covered by tests driving a fake clock through entry, silence, exit, re-entry, and per-region notify flags.
Fix
Restarting monitoring crashed with a duplicate key. StartScan added every stored region into a dictionary it never cleared, and the repository-change handler added again on top of that, so a second start - or a region re-registered with new bounds - threw An item with the same key has already been added. Re-registering an identifier now updates the region in place, which is a legitimate way to change what a region matches.
Fix
Major = 0 was rejected as invalid. BeaconRegion threw ArgumentException on major < 1, making zero - a perfectly legal iBeacon major and minor value - impossible to monitor or range. Only the genuine rule remains: a minor requires a major.
Fix
Any manufacturer payload starting 0x02 0x15 was parsed as an iBeacon, whichever company identifier it arrived under. The company id is now checked against Apple’s 0x004C; BeaconRangingOptions.AllowNonAppleCompanyId re-enables the loose behaviour for the handful of vendors that ship iBeacon-shaped payloads under their own identifier.
FixAndroid
The monitoring foreground service declared the wrong type. It ran as ForegroundService.TypeLocation, but a BLE scan is a connected-device activity, and from Android 14 the declared type has to match what the service does or the service is refused outright. It is now TypeConnectedDevice and asks for FOREGROUND_SERVICE_CONNECTED_DEVICE - which also means beacon monitoring no longer prompts for a location permission it never used. Add <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> to your manifest.
FixAndroid
Ranging scanned in ScanMode.Balanced, which delivers a fraction of the advertisements the radio could report - and averaging is only as good as its sample count. Ranging now uses LowLatency and monitoring LowPower, with scan batching off for both: batched results arrive with stale, coalesced RSSI values that defeat the filter.
FeatureiOS
Region monitoring uses CLMonitor with CLBeaconIdentityCondition on iOS and Mac Catalyst 18+, the same path Shiny.Locations already takes for geofences - including the CLServiceSession background delivery needs and the cold-start replay suppression that stops a re-attached condition from firing a spurious entry. Below 18 it falls back to CLLocationManager region monitoring, where iOS’s 20-region-per-app cap (shared with geofences) is now enforced with a named error instead of iOS silently dropping the excess.
FeaturemacOS
macOS ranges iBeacons and reads Eddystone. It has no beacon region API in CoreLocation at all, so the monitoring manager registers - keeping shared startup code compiling and running - but reports AccessState.NotSupported and throws from StartMonitoring. Branch on CurrentStatus, not on OperatingSystem.IsMacOS().
FeatureLinux
Linux broadcasts both formats. This needed BlueZ advertising implementing in Shiny.BluetoothLE.Hosting.Linux first, where StartAdvertising was an unimplemented stub - see the BluetoothLE Hosting release notes. Linux is now the only non-Apple platform where every beacon capability works: ranging, monitoring, Eddystone and broadcasting.
FeatureBlazor
Shiny.BluetoothLE.Blazor now surfaces manufacturerData and serviceData from Web Bluetooth advertisements, which it previously discarded - IAdvertisementData.ManufacturerData and .ServiceData both returned null. Both beacon formats are therefore readable in the browser wherever navigator.bluetooth.requestLEScan is available (Chromium, behind #enable-experimental-web-platform-features). The chooser fallback carries no advertisement payload, so beacons stay invisible through that path.
FeaturetvOS
tvOS support. Shiny.Net.Discovery now ships a net10.0-tvos target with the full API - mDNS/DNS-SD browsing, resolution and publishing over NSNetService, plus the managed SSDP and WS-Discovery transports. As on iOS, Bonjour goes through the system mDNSResponder rather than raw multicast, so you need NSBonjourServices and NSLocalNetworkUsageDescription in Info.plist but not the multicast entitlement.
FeaturetvOS
tvOS support. Shiny.ScreenRecorder now ships a net10.0-tvos target. ReplayKit records the app’s own UI on tvOS just as it does on iOS, including system audio, pause/resume, bitrate control and downscaling. ScreenRecorderCapabilities.Microphone is not advertised on tvOS - an Apple TV has no microphone and RPScreenRecorder carries no MicrophoneEnabled there - so a request with IncludeMicrophone is rejected by validation.

Initial Release

Feature
New Shiny.ScreenRecorder package — cross-platform screen recording for Android, iOS, Mac Catalyst, macOS and Windows, with Shiny.ScreenRecorder.Linux covering Linux through the xdg-desktop-portal ScreenCast API and Shiny.ScreenRecorder.Blazor covering the browser. Register services.AddScreenRecorder() and inject IScreenRecorder to record the screen to a video file with optional microphone and system audio, pause and resume, and pick a display or window on desktop.
Feature
IScreenRecorder.Capabilities publishes a ScreenRecorderCapabilities flags value describing what the current platform can actually do, and a request asking for something outside it throws ScreenRecorderNotSupportedException before any native call happens — because a recording that silently came out without the microphone, at the wrong frame rate, or of the wrong display is worse than one that refused to start. The flags are read off the instance rather than inferred from the target framework, because they differ within a platform as well as between them.
Feature
Start(request) returns an IScreenRecording only once frames are genuinely being written, not when the request was accepted — so a consent dialog, a compositor picker or an Android foreground-service promotion all complete first. Stop() returns a ScreenRecordingResult with the path, duration, byte size, encoded dimensions and the MIME type actually produced. Disposing the session without stopping cancels it and deletes the partial file. One recording at a time: Start throws while another is in flight, matching the restriction every platform underneath already has.
Feature
IScreenRecording.Faulted reports the OS ending a recording without being asked — the user revoking the capture, the system pre-empting it, the target going away, the encoder failing, or MaxDuration elapsing — and carries whatever was salvaged, so a recording cut short still yields a playable file rather than a silent truncation. This is not an edge case: it is the normal way a screen recording ends on several of these platforms.
Feature
Pause()/Resume() close the gap in the timeline rather than leaving a frozen stretch in the middle of the file — later timestamps are shifted back by the paused span. Only the browser pauses natively; everywhere else the capture keeps running and frames are dropped, so a long pause still costs battery. Elapsed excludes the paused span and matches the duration of the finished file.
Feature
GetTargets() lists displays, windows and applications on macOS and Windows so an app can offer its own picker, with Target on the request selecting one. Mobile has no concept of a target, and Linux and the browser hand selection to the compositor — both throw here and show their own picker during Start instead.
FeatureAndroid
Backed by MediaProjection into a VirtualDisplay, encoded with MediaCodec (a surface-fed H.264 encoder plus AAC) into a MediaMuxer. MediaCodec rather than the far simpler MediaRecorder for one reason: MediaRecorder.setAudioSource takes a single source and playback capture is not one of them, so app audio is only reachable through AudioRecord + AudioPlaybackCaptureConfiguration — wanting it at all forces the whole pipeline down. Mic and app audio are summed with saturation when both are requested.
FeatureAndroid
Consent runs through a transparent, no-history activity in the package, and the required mediaProjection foreground service is started before getMediaProjection is called — the ordering Android 14 (API 34) made mandatory and which throws SecurityException if reversed. Both are merged into the consuming app’s manifest automatically. Screen consent cannot be pre-granted, so RequestAccess answers only for the microphone and reports AccessState.Unknown for the screen.
FeatureiOS
Backed by RPScreenRecorder.startCapture feeding an AVAssetWriter — not startRecording, which keeps the movie inside ReplayKit and only surrenders it through RPPreviewViewController, a user-facing share sheet that is no use to a library promising a file path. This records the app’s own UI only; system-wide capture needs a Broadcast Upload Extension, a second app target no NuGet package can deliver. The app must be in the foreground, and ReplayKit stopping on an incoming call or a screen lock arrives as Faulted rather than as a truncated file.
FeaturemacOS
Backed by ScreenCaptureKit, with two paths. macOS 15+ uses SCRecordingOutput, which writes the MP4 itself and adds microphone capture — but cannot be paused, since a recording output cannot be detached and reattached mid-file. macOS 12.3–14 pumps SCStream sample buffers through the same AVAssetWriter the Apple mobile backend uses, which can synthesise a pause but has no microphone source. PauseResume and Microphone therefore differ by OS version rather than the modern path being crippled to make the flags uniform. SCShareableContent supplies displays, windows and applications for GetTargets(), and CGPreflightScreenCaptureAccess/CGRequestScreenCaptureAccess drive the Screen Recording prompt — neither is bound in the macOS SDK, so both are reached by LibraryImport.
FeatureWindows
Backed by Windows.Graphics.Capture — a free-threaded Direct3D11CaptureFramePool bridged into a MediaStreamSource and encoded to MP4 by MediaTranscoder, so no frame is ever copied into managed memory. Displays and windows come from IGraphicsCaptureItemInterop rather than the system picker, so GetTargets() returns a real list the app controls. The COM and D3D11 interop is hand-rolled through vtable calls rather than ComImport, keeping the package AOT- and trim-clean.
FeatureWindows
Windows records video only. Windows.Graphics.Capture has no audio path at all — unlike every other platform here, its capture API captures pixels and nothing else. Capabilities reports neither Microphone nor SystemAudio and requesting either throws, rather than silently producing a silent file. System audio would require a hand-written WASAPI loopback capture and is tracked separately.
FeatureLinux
Shiny.ScreenRecorder.Linux drives org.freedesktop.portal.ScreenCast over D-Bus for consent and frames, then hands the PipeWire node to gst-launch-1.0 (Wayland and X11) or falls back to ffmpeg -f x11grab on X11. Stopping sends SIGINT rather than SIGKILL, because both encoders need it to flush and write the MP4 index — a killed encoder leaves a file no player will open. Encoder availability, the session type and the PulseAudio monitor source are all probed at runtime, so a machine missing the pieces reports ScreenRecorderCapabilities.None rather than failing when the user presses record. Flatpak sandboxes are not supported.
FeatureBlazor
Shiny.ScreenRecorder.Blazor records through getDisplayMedia and MediaRecorder. It is the only platform where pause is native, and the only one where ScreenRecordingResult.FilePath is null — the browser has no filesystem, so OpenRead() streams the blob back through an IJSStreamReference and DownloadRecording(result, fileName) hands it to the user. The container varies by browser and the result says which: Safari and recent Chrome produce MP4/H.264, Firefox produces WebM/VP9. Display audio and the microphone are summed through a WebAudio graph when both are requested, since MediaRecorder encodes only one audio track. Requires a secure context and a user gesture, and Probe() must run once before Capabilities reports anything.
Feature
ScreenRecordingResult.OpenRead() is the portable way to read a recording back — it opens the file where there is one and streams the blob out of JavaScript where there is not, so calling code stays the same across every platform. MimeType reports what was actually produced rather than what was asked for.
Feature
MaxWidth downscales while preserving aspect ratio, rounding both sides to even because H.264 cannot encode odd dimensions — worth setting on almost any recording, since a modern phone or Retina display at native resolution produces very large files for very little visible gain. When no bitrate is given, one is estimated from the resolution and clamped to a range that keeps screen text legible without producing gigabyte files.
FeaturetvOS
tvOS support. Shiny.Jobs now ships a net10.0-tvos target. tvOS carries the same BGTaskScheduler iOS does, so background processing jobs schedule and run identically - declare BGTaskSchedulerPermittedIdentifiers and the processing background mode in Info.plist exactly as you would on iOS. Jobs do not run on the tvOS simulator, matching the iOS behaviour.
Fix
Fixed trim/AOT warning IL2091 from JobRegistrar.Register<TJob>. The TJob generic on Register<TJob> and AddJob<TJob> now carries DynamicallyAccessedMemberTypes.Interfaces alongside PublicConstructors, matching what AddSingletonAsImplementedInterfaces requires so the job’s interfaces survive trimming.
FeaturetvOS
tvOS support. Shiny.Push now ships a net10.0-tvos target. Registration, token handling and silent/background push (IPushDelegate.OnReceived) work as they do on iOS. What tvOS does not have is any notification a user can see or tap: UNNotificationContent there carries nothing but a badge count, so there is no UNNotificationResponse, no foreground presentation, and OnEntry is never raised. RequestAccess() accordingly asks for UNAuthorizationOptions.Badge alone on tvOS rather than Alert + Badge + Sound.
FeatureAndroid
The notification channel is no longer hard-coded English. AddLiveActivities(configure) and AddLiveActivities&lt;TDelegate&gt;(configure) take a new LiveActivityOptions carrying ChannelName and ChannelDescription - what the user reads in Android’s per-app notification settings. They are re-applied on every startup rather than only at first channel creation, because Android treats a repeat registration with the same channel id as an update to those two fields, so a translation that ships after first launch still reaches the settings screen. Channel importance and sound stay unconfigurable on purpose: Android ignores both once the channel exists, and from that point they belong to the user. iOS is unaffected - an ActivityKit activity has no channel.
Feature
New module: Shiny.Mobile.LiveActivities. One typed API in front of the persistent, updating status surface both phone platforms grew independently - iOS/iPadOS Live Activities (ActivityKit, Lock Screen and Dynamic Island) and Android 16 Live Updates. ILiveActivityManager starts, updates and ends them; LiveActivityContent carries title, body, short status, progress, stale date, relevance score and a free-form string/string data bag your own widget reads. The shared contract is a state, not a UI tree, so the two platforms cannot drift in what they say. See Getting Started.
FeatureiOS
ActivityKit is Swift-only with no Objective-C interface, so it is reached through an @objc shim built from an Xcode project and shipped as Shiny.iOS.LiveActivities.Binding - pulled in automatically, never referenced directly. Requires iOS/iPadOS 16.2+ and a SwiftUI widget extension in the app bundle; a ready-made one ships in templates/WidgetExtension and its Swift is compile-checked in CI. See The iOS Widget Extension.
FeatureAndroid
Android 16 (API 36+) uses Notification.ProgressStyle with requestPromotedOngoing and setShortCriticalText, earning the status bar chip and always-on-display treatment. Android 8-15 degrades to an ordinary ongoing notification with a determinate progress bar. RequestAccess() asks for POST_NOTIFICATIONS on API 33+.
FeatureiOS
Both APNs token kinds are surfaced through ILiveActivityDelegate: OnPushTokenChanged for a single activity’s update token, and OnPushToStartTokenChanged for the device’s push-to-start token (iOS 17.2+), which lets a server start an activity with the app closed. They pair with PushTokenKind.LiveActivityUpdate / .LiveActivityStart in Shiny.Extensions.Push. Send an ordinary alert to one of these and APNs answers DeviceTokenNotForTopic, so keep the kinds separated. See Push Tokens & Server Updates.
Feature
LiveActivityProgress.FromRange(start, end) emits a self-animating time range the system advances on its own, instead of a fixed fraction. Strongly preferred for anything time-based: every push update costs budget, and a suspended iOS app sends none at all, so a fraction-based bar freezes until the app wakes.
Feature
LiveActivityContentSchema is public - the JSON contract shared by this library, the Swift ShinyActivityAttributes.ContentState a widget renders, and the content-state a server pushes - so a server payload can be built or verified against exactly the shape the app produces. Note that dates inside content-state are seconds since 2001-01-01 (Swift’s stock Codable encoding), not Unix; a drift here does not throw, it silently stops the activity refreshing.
Chore
Mobile only, and the package name says so. iOS/iPadOS and Android are the entire supported list; macOS, Mac Catalyst, tvOS, Windows, Linux and Blazor resolve NoOpLiveActivityManager, where IsSupported is false and every call is a safe no-op - so shared view models need no platform checks. There is no Apple desktop implementation to add: ActivityKit.framework ships in the macOS SDK, but every public type in it is annotated @available(macOS, unavailable) / @available(macCatalyst, unavailable) / @available(tvOS, unavailable) / @available(watchOS, unavailable). What a Mac shows is an iPhone’s activity mirrored over iPhone Mirroring, which no Mac app declares or drives - hence Shiny.iOS.LiveActivities.Binding rather than Shiny.Apple.*.
EnhancementiOS
The Live Activity renderer for transfer progress now ships inside Shiny.Net.Http. There is no longer a separate package and no second registration call - AddTransferProgress() registers the iOS renderer exactly as it already registered the Android one, and pulls Shiny.Mobile.LiveActivities in on the -ios target alone, so no other head carries ActivityKit. The two ActivityKit-only knobs moved onto the same options object as opts.LiveActivity.Kind and opts.LiveActivity.RequestPushToken. iOS still needs the widget extension from templates/WidgetExtension and NSSupportsLiveActivities in Info.plist. See Transfer Progress and Live Activities.
FeaturetvOS
tvOS support. Shiny.Net.Http now ships a net10.0-tvos target backed by the same background NSUrlSession the iOS implementation uses, so uploads and downloads continue while the app is suspended. Note that an Apple TV has no user-visible file system and limited persistent storage - write transfer output to the cache directory and treat it as evictable.
FeaturetvOS
tvOS support. Shiny.Data.Sync now ships a net10.0-tvos target, using the same NSUrlSession-backed DataSyncManager as iOS and Mac Catalyst.
FixAndroid
IBleManager.GetKnownPeripheral(uuid) now resolves a persisted identifier without a scan. The lookup only ever searched the in-process peripheral cache, which is populated by scanning, so after a process restart it could never hit and reconnecting to a saved device required a fresh scan on every cold start. It now falls back to BluetoothAdapter.GetRemoteDevice - decoding the MAC back out of the identifier that Peripheral.GetUuid encoded it into - and returns the same cached peripheral instance a later scan would hand back, matching what Apple already does with RetrievePeripheralsWithIdentifiers. Note that Android has no way to ask whether the OS has seen an unbonded LE device, so a well-formed identifier now always resolves and an absent peripheral fails at connect rather than at lookup.
Fix
IPeripheral.Mtu and BleServiceContext.Mtu now mean the same thing on every platform. Both were documented as the “negotiated MTU” while the value returned differed by platform - Apple gave the usable ATT payload (MaximumUpdateValueLength), Android, Windows, and Linux gave the raw ATT MTU. Sizing a notification against it was therefore correct on Apple and 3 bytes over the link limit everywhere else, which some stacks silently truncate rather than reject. The property is now the usable payload - the negotiated ATT MTU minus the 3-byte ATT header - on all platforms, matching Shiny.BluetoothLE’s IPeripheral.Mtu. Cap notification and read payloads at Mtu directly; do not subtract the header again. Linux hosting’s pre-negotiation default drops from 23 to 20 for the same reason.
BREAKINGFixAndroid
IPeripheral.Mtu was assigned the raw onMtuChanged value with no header subtraction, so it started life as a payload (20) and became an ATT MTU after the central negotiated - two different units on one property. It now subtracts BleConstants.AttHeaderSize consistently. If you were compensating with context.Mtu - 3, remove the subtraction; if you were using Mtu as-is you were overshooting by 3 and are now correct.
BREAKINGFixWindows
IPeripheral.Mtu returned GattSession.MaxPduSize, which is the ATT MTU. It now subtracts the 3-byte ATT header. Same migration note as Android.
Feature
Source generator for GATT services and L2CAP listeners. Put [BleService] / [L2CapService] on a partial class and the generator emits the AddService(...) / OpenL2Cap(...) calls, the GattResult wrapping, the IsReplyNeeded/Respond and offset handling, the notify push API (NotifyX / XSubscribers / HasXSubscribers), and AddBleHostedServices() / AttachBleHostedServices(sp) / StartBleHostedAdvertising(name) extension methods on IBleHostingManager. Handler parameters bind by type in any order and any subset — ReadRequest/WriteRequest, the raw byte[], the offset, IPeripheral, a CancellationToken, and the generated context all just work. Nothing reflective is emitted, so it replaces the managed pattern removed in 5.0.0 without giving up AOT-cleanliness. The generator ships inside Shiny.BluetoothLE.Hosting under analyzers/dotnet/cs — nothing extra to install. See the new Source Generator page.
Feature
Per-connected-central context. Each [BleService] class gets a generated {ServiceClass}Context — a partial class you add your own properties to — created lazily per central and passed to any handler that declares it as a parameter. It carries Peripheral, ConnectionId, Mtu, ServiceUuid, Service, and a loosely typed Items bag, in the spirit of SignalR’s Hub.Context. Storage is a ConditionalWeakTable keyed on the peripheral rather than the public IPeripheral.Context slot, so it never collides with app state.
Feature
[RequestResponseCharacteristic] registers a characteristic as Write | Notify and pushes the handler’s returned bytes back to the central that wrote — a GATT write response cannot carry a payload, so the reply travels as a notification addressed to that central. Implement the generated OnBleResponseDropped hook to observe the case where the central was not subscribed.
Feature
[L2CapService(PsmService = ..., PsmCharacteristic = ...)] publishes the platform-assigned PSM as a GATT read characteristic (two little-endian bytes) on a service in the same compilation, which is the only in-band way a central can learn it. Listeners are opened before AddService, so a read immediately after registration returns a live value. L2CapChannel.ReadAll(cancellationToken) is a new IAsyncEnumerable convenience over the Rx DataReceived observable.
Enhancement
Several [BleService] classes may declare the same service UUID — the generator merges them into a single AddService call, which matters because BleHostingManager keys its services by UUID and would throw on a second registration. Declaring the same characteristic UUID in two merged classes is a compile error.
Enhancement
Fourteen compile-time diagnostics (SBH001-SBH014) cover invalid UUIDs, duplicate handlers for one characteristic, unbindable signatures, dangling PSM publications, and merge conflicts — mistakes that used to surface as a silent no-op or a runtime throw on device.
Enhancement
Generated UUIDs are always emitted in the full 128-bit form. Short forms like "180D" are accepted by Apple’s CBUUID.FromString but throw on Android, which goes through java.util.UUID.fromString — so a service that worked on iOS could fail on Android. This only applies to generated code; write full UUIDs yourself when calling AddService directly.
FixiOS
A background RequestAccess can now raise the “always” prompt after a foreground one has already run. GpsManager kept a single CLServiceSession and created it with ??=, so the first caller fixed the authorization requirement for the life of the process — an app that asked for GpsRequest.Foreground first (anything with a live map or a HUD) opened a when-in-use session, and every later GpsBackgroundMode.Standard / Realtime request reused it and could never escalate. The short-circuit above it made that silent: AuthorizedWhenInUse maps to AccessState.Restricted the moment a background request asks about it, which is exactly the state the “always” prompt exists to upgrade, so RequestAccess returned a refusal without presenting anything. The session is now created per requirement and replaced when a stronger one is needed — an existing always session still covers a foreground request, so a background grant is never torn down to ask for less.
FixiOS
RequestAccess no longer times out while the user is reading the permission dialog. The 30-second wall clock covered the whole exchange, so anyone who took their time answering got a TimeoutException thrown out from under a dialog still on screen. It now covers only the session failing to report any diagnostic at all, and is disarmed as soon as the OS says a request is in progress.
FixiOS
Concurrent RequestAccess calls are serialized. Each call now owns the session it waits on, so two in flight together would otherwise leave the first waiting on a handler that had been replaced.
FixAndroid
Resolving a service on Android 14 (API 34) or later no longer crashes the app with Java.Util.Concurrent.RejectedExecutionException. NsdManager dispatches the unregistration confirmation through the Executor handed to registerServiceInfoCallback after unregisterServiceInfoCallback returns, so shutting that executor down at the end of a resolve left the framework rejecting its own task on its handler thread - an uncaught exception that killed the process. Callbacks now run inline on NsdManager’s dispatch thread with nothing to shut down. Affects Browse/BrowseOnce with MdnsBrowseConfig.ResolveServices enabled and direct IMdnsManager.Resolve(...) calls.

Initial Release

Feature
New Shiny.Net.Wifi package — cross-platform Wi-Fi for Android, iOS, Mac Catalyst, macOS and Windows, with Shiny.Net.Wifi.Linux covering Linux through NetworkManager. Register services.AddWifi() and inject IWifiManager to scan for access points, join and leave networks, manage the networks the device has saved, read the current network, and power the radio. Backed by WifiManager + ConnectivityManager on Android, NEHotspotConfiguration + CaptiveNetwork on iOS/Mac Catalyst, CoreWLAN on macOS, the WiFiAdapter WinRT API on Windows, and NetworkManager over D-Bus on Linux.
Feature
IWifiManager.Capabilities publishes a WifiCapabilities flags value describing what the current platform can actually do, and anything unavailable throws WifiNotSupportedException naming the specific limit. Wi-Fi is the most unevenly exposed capability across these platforms — iOS has no scanning API, neither phone OS will show an app the networks the user saved, only three platforms can raise a hotspot — and the API states that rather than returning empty results that are indistinguishable from a quiet neighbourhood.
Feature
Scan(...) returns one WifiNetwork per BSSID — SSID, WifiSecurity (Open/WEP/WPA/WPA2/WPA3/Enterprise/OWE), signal in both dBm and a 0-100 percentage, frequency, and derived Band and Channel. Results are strongest-first. Networks in a WPA2/WPA3 or OWE transition mode report the stronger scheme, so a caller never sees a network as weaker than it is.
Feature
Connect(new WifiConnectionRequest(ssid) { Passphrase = ... }) joins a network and returns only once an address has been assigned, not merely on association — a result with no IP on it is not useful to the caller. Disconnect() leaves. WifiConnectionException carries the reason a join failed: wrong passphrase, out of range, user declined the system prompt, or DHCP timeout.
Feature
Known networks — GetKnownNetworks() lists what the device has saved as KnownWifiNetwork (an opaque platform-issued Id, plus SSID, security, hidden flag and AddedByThisApp), Forget(id) deletes one, and Connect(id) rejoins one without the passphrase being handed over again. The three sit behind the KnownNetworks, ForgetNetwork and ConnectKnownNetwork capability flags. Id is the platform’s own handle — a NetworkManager connection UUID on Linux, a numeric network id on Android below API 29, the SSID everywhere else — so round-trip it rather than parsing it.
Feature
What counts as “known” is scoped differently per platform and the API says which rather than papering over it: iOS, Mac Catalyst and Android disclose only the entries your own app created, while Windows, macOS and Linux hand back every profile on the machine. Neither phone OS will show an app the networks the user saved themselves, so AddedByThisApp exists to tell the two cases apart and a “manage all my Wi-Fi networks” screen is a desktop-only idea.
FeatureiOS
Saved networks on iOS and Mac Catalyst map onto NEHotspotConfigurationManagergetConfiguredSSIDs for the listing and removeConfiguration(forSSID:) for Forget. Only names come back; a stored configuration carries no security type or hidden flag. Connect(id) is not available: a configuration is a standing instruction iOS acts on when the network is in range, and there is no call to force the join.
FeatureAndroid
Remember = true now does something on Android 11+. The specifier join is still what gets the device onto the network, but a WifiNetworkSuggestion is registered alongside it so the OS can rejoin later — which is also what makes the network visible to GetKnownNetworks(). The suggestion only takes effect once the user approves the notification Android raises, and a failure to register one never fails the join. Below API 29 the legacy WifiConfiguration list is used instead, and its numeric network id is a real handle, so Connect(id) works there.
FeatureWindows
Saved profiles are outside WinRT entirely — WiFiAdapter cannot list, delete or join one — so GetKnownNetworks(), Forget() and Connect(id) P/Invoke wlanapi.dll directly (WlanGetProfileList, WlanGetProfile, WlanDeleteProfile, WlanConnect) rather than shelling out to netsh and parsing localised console output. Security and the hidden flag are read out of each profile’s XML; one that cannot be parsed is still reported, as unknown.
Feature
macOS lists the machine’s preferred-network list from CWConfiguration.networkProfiles and rejoins from the login keychain, so your app never sees the passphrase. Forget() there means committing a whole CWConfiguration, which macOS gates behind an SFAuthorization a normal app cannot raise — it throws WifiPermissionException explaining that. Linux reads, activates and deletes NetworkManager’s saved connections directly, with deletion gated on the polkit action org.freedesktop.NetworkManager.settings.modify.system.
Feature
IWifiManager.GetCurrentNetwork(ct) reports the joined network as a WifiNetworkInfo — SSID, BSSID, security, signal and frequency alongside every IP address, DNS resolver, gateway and subnet mask, with IPv4Address/IPv6Address shortcuts. The Changed event fires with the new WifiNetworkInfo? (null when the device drops off Wi-Fi) and is de-duplicated: the native watchers behind it all fire several times per real change. Subscribing now delivers the current network once up front, so a handler does not sit blind until the network next moves. WifiNetworkInfo compares its address lists by value rather than by reference, so snapshots can be diffed directly. The addressing fields are best-effort individually: a platform is allowed not to implement one — GatewayAddresses is unsupported on Android, and the sandboxed platforms vary on the rest — and a refused field comes back null or empty rather than throwing, so one missing field never costs the caller the whole read.
FixiOS
The SSID and BSSID of the joined network came back null on iOS 14 and later. CNCopyCurrentNetworkInfo was the source, and since iOS 14 it returns nothing unless the calling app itself configured the network being asked about — it succeeds and hands back an empty dictionary otherwise, so nothing failed and the name was simply missing. Reading now goes through NEHotspotNetwork.fetchCurrent, the iOS 14 replacement, which needs only the Access WiFi Information capability and location — not the case-by-case NEHotspotHelper entitlement its instance-side API once did. It reports more than CaptiveNetwork ever could, so Security and SignalStrengthPercent are now populated on iOS and Mac Catalyst instead of sitting at their defaults.
FixAndroid
The SSID and BSSID of the joined network came back null on Android 12 (API 31) and later even with ACCESS_FINE_LOCATION granted. API 31 redacts both out of every pull-style read — WifiManager.getConnectionInfo and the WifiInfo hanging off getNetworkCapabilities alike — regardless of permissions, and hands them out only through a NetworkCallback registered with FLAG_INCLUDE_LOCATION_INFO. Shiny now registers its watcher with that flag and serves reads from what it delivers, falling back to a short-lived callback (with a timeout, so a device that is off Wi-Fi returns null rather than hanging) when no watcher is running. Below API 31 the legacy getConnectionInfo path is unchanged — the redaction is what arrived in 31, not the deprecation.
BREAKINGFeature
IWifiManager.CurrentNetwork is replaced by Task&lt;WifiNetworkInfo?&gt; GetCurrentNetwork(CancellationToken ct = default). The synchronous property could not be made correct: the two platforms the module exists for both stopped answering synchronously, and a property that silently reports a null SSID is worse than one that makes the cost visible. Replace wifi.CurrentNetwork with await wifi.GetCurrentNetwork(); a Changed handler needs no change at all, since the new network already arrives in the event argument. On macOS, Windows, Linux and plain .NET the read is still synchronous underneath and the Task completes immediately.
Enhancement
New WifiSecurity.Psk — a pre-shared key whose WPA generation the platform did not name. iOS reports “personal” and nothing finer, and reporting that as Unknown would throw away what iOS does know while picking one of WPA/WPA2/WPA3 would be a guess. A switch over WifiSecurity that only handles Wpa2Psk/Wpa3Psk needs a case for this.
Feature
New IWifiHotspot (services.AddWifiHotspot()) raises an access point. Start(...) returns an IHotspotSession carrying the SSID and passphrase actually in use; disposing it brings the access point down. GetClients() lists joined devices with MAC address, IP and host name.
FeatureAndroid
The Android hotspot is a local-only hotspot — clients reach the device but get no route to the internet, because real tethering sits behind the signature permission TETHER_PRIVILEGED. The OS also generates the SSID and passphrase and offers no supported way to choose them (SoftApConfiguration.Builder exposes only the channel to non-system apps), so read them back off IHotspotSession.Info and show them to the user. GetClients() throws — Android has never exposed a client list and blocked the /proc/net ARP table apps read instead in Android 10.
FeatureWindows
Windows hotspot support goes through NetworkOperatorTetheringManager — real tethering that shares the machine’s current internet connection, honours the SSID, passphrase and band you supply, and can enumerate connected clients. Starting one with the machine offline fails, since there is no connection to share.
Feature
Linux hotspot support runs NetworkManager AP mode with ipv4.method=shared for DHCP and NAT, honours the full HotspotConfiguration, and lists clients from the kernel neighbour table filtered to the hotspot interface.
FeatureiOS
iOS and Mac Catalyst support joining, leaving and reading the current network, and nothing else. There is no scanning API — the only one that lists nearby networks lives inside a NEHotspotHelper, an entitlement Apple grants case by case — no hotspot API and no radio toggle. Requires the Hotspot Configuration and Access WiFi Information App ID capabilities plus NSLocationWhenInUseUsageDescription; without the location grant the SSID and BSSID come back null rather than failing, which is why RequestAccess() asks for it.
FeatureAndroid
Android needs ACCESS_WIFI_STATE, CHANGE_WIFI_STATE and ACCESS_FINE_LOCATION, plus NEARBY_WIFI_DEVICES from API 33. Joining uses a WifiNetworkSpecifier from API 29 (a system dialog, and the join itself is never persisted) and falls back to the legacy WifiConfiguration path below it. The radio toggle drops out of Capabilities from API 29, where setWifiEnabled was revoked for third-party apps.
Feature
macOS is backed by CoreWLAN and supports the full station API — scanning, associating, disassociating and powering the interface. Needs NSLocationWhenInUseUsageDescription, since macOS 14 gates scan results and the SSID on location the way iOS does. Windows needs the wiFiControl and radios capabilities; Linux gates the mutating calls behind the polkit actions org.freedesktop.NetworkManager.network-control and …enable-disable-wifi.
Enhancement
A scan that comes back empty because a permission was refused throws WifiPermissionException naming what is missing, rather than looking like an empty neighbourhood — Android returns an empty list instead of an error in that case. Android’s &lt;unknown ssid&gt; placeholder and the all-zero BSSID it substitutes without location permission are reported as null rather than passed through as literal strings.
Enhancement
WaitForAddress — the poll every connect path runs so a join returns with an address on it rather than on bare association — moved onto AbstractWifiManager instead of being copied into the Android, macOS, Windows and Linux managers. Linux also dropped the cache it kept purely to satisfy the old synchronous property, along with the blocking Task.Run(...).Wait(5s) prime behind it, and AndroidWifiManager.Dispose no longer blocks on its own Disconnect task.
FixAndroid
The internal receiver that picks up scan results below API 30 no longer carries a [BroadcastReceiver] attribute. It is registered from code — it has to be, since SCAN_RESULTS_AVAILABLE_ACTION is an implicit broadcast a manifest-declared receiver cannot get on API 26+ — but the attribute still put a <receiver> node in the generated manifest, and .NET for Android then failed the build of any app referencing the package with XA4213: The type 'Shiny.Net.Wifi.ScanReceiver' must provide a public default constructor.
Chore
The plain .NET target of the base package is a deliberate stub rather than a fallback: GetCurrentNetwork() still reports the IP, DNS, gateway and mask of the wireless interface and Changed still fires off NetworkChange, because that comes from the managed network stack — but every Wi-Fi-specific call throws, pointing at Shiny.Net.Wifi.Linux where relevant. Capabilities reports None.
BREAKINGFeatureAndroid
New AndroidConfiguration sets app-wide defaults for how a notification tap launches your app - LaunchActivityFlags and LaunchActivityType - registered with services.AddNotifications(new AndroidConfiguration(...)). The shipped default is unchanged (NewTask | ClearTask), but apps whose taps should resume the running activity rather than cold start a fresh copy can now register NewTask | ClearTop | SingleTop instead. Breaking: AndroidNotification.LaunchActivityFlags is now ActivityFlags? (null = use the configured value) rather than ActivityFlags; assignment is unaffected, only code that reads the property needs updating. AndroidNotificationManager’s constructor also takes the new AndroidConfiguration, which matters if you subclass or construct it yourself. See Platform Specific.
FixAndroid
AndroidNotification.LaunchActivityType no longer loses its intent flags. Setting a specific launch activity built the intent with no flags at all, while the ClearTask branch that decides between TaskStackBuilder and PendingIntent.GetActivity still read the configured value - so a custom activity launched with mismatched behaviour. The flags are now applied on both paths.
Feature
Transfer progress surfaces. New services.AddTransferProgress() mirrors background transfers onto whatever surface the platform offers - an iOS Live Activity, or the Android foreground-service notification - from one TransferProgressManager on every platform. It subscribes to UpdateReceived at startup, coalesces the progress firehose to one update a second, aggregates a batch into a single figure, and starts/updates/retires the surface, including when iOS relaunches the app in the background to finish a transfer. Nothing goes in your transfer delegate. What is shown is configured with the TransferProgressFields flags (file name, direction, percent, bytes, speed, time remaining, host) and TransferProgressShortStatus; wording is overridable through ITransferProgressDelegate. See Transfer Progress.
FixAndroid
Transfers no longer post two notifications. Android needs a foreground service to move bytes in the background, and that service must post a notification - but PerTransferNotificationStrategy posted its progress as a second notification, leaving the user with a progress entry alongside a redundant “Shiny service is continuing to transfer data in the background”. The new ForegroundNotificationRenderer re-posts the service’s own notification id instead, so there is exactly one.
EnhancementAndroid
The transfer notification is promoted to an Android 16 live update on API 36+ - Notification.ProgressStyle with requestPromotedOngoing and setShortCriticalText, which earns the status bar chip and always-on-display treatment. Android 8-15 degrades to an ordinary determinate progress bar.
EnhancementiOS
Progress can be emitted as a self-animating time range rather than a fraction (ProjectTimeRemaining, on by default). A background NSURLSession delivers no callbacks at all while the app is suspended, so a fraction-based bar freezes for most of a long transfer; a range keeps advancing with no app involvement, anchored in the past so the bar already sits at the true fraction rather than snapping to zero on every update. Falls back to a fraction when the transfer is stalled, paused, of unknown size, or the estimate exceeds MaximumProjection.
Feature
ITransferProgressRenderer is public: register your own and the same manager drives it, so you inherit the aggregation, coalescing and lifetime and implement only the drawing. TransferProgressContentBuilder is public and static too - FormatBytes, FormatRate, FormatDuration and FormatPercent are reusable in ordinary in-app progress UI.
BREAKINGChoreAndroid
PerTransferNotificationStrategy has been removed, along with AbstractTransferNotificationStrategy and the never-implemented SummaryTransferNotificationStrategy. It was the source of the duplicate notification above. Delete services.AddShinyService<PerTransferNotificationStrategy>() and call services.AddTransferProgress() instead - it covers Android and iOS from one registration, and ForegroundNotificationRenderer is protected virtual where the old strategy’s Customize was, so custom notification styling ports over. See Transfer Progress.
FixAndroid
AndroidLifecycleExecutor is now built from an explicit factory delegate instead of via container constructor selection. It derives from Java.Lang.Object and therefore must expose the (IntPtr, JniHandleOwnership) JNI constructor; containers that select a constructor by “most resolvable arguments” (DryIoc’s ConstructorWithResolvableArguments, used by Prism) had to weigh that overload and reported a misleading Error.UnableToFindCtorWithAllResolvableArgs on the executor whenever a real dependency further down the graph failed to resolve. The dependency that actually failed in practice was the keyed IKeyValueStore injected by AndroidPlatform — see the Stores 5.1.4 release notes for that half of the fix.
Fix
IPeripheral.Mtu is documented correctly at last. Mtu, ICanRequestMtu.RequestMtu, TryRequestMtu, and TryRequestMtuAsync all described their value as the “negotiated MTU”, but every platform returns the usable ATT payload - the negotiated ATT MTU minus the 3-byte ATT header. Anyone who took the docs literally and subtracted the header before fragmenting removed it twice; worse, anyone who “corrected” working code on the strength of the docs, or fed Mtu to an API that genuinely wants an ATT MTU, ended up 3 bytes over the link limit, which some stacks silently truncate rather than reject. The behaviour is unchanged on Android, Apple, and Windows - Shiny.BluetoothLE’s own WriteCharacteristicBlob has always chunked to Mtu as a payload - only the XML docs and the site were wrong. Note the deliberate asymmetry: RequestMtu(512) takes an ATT MTU (it goes straight to BluetoothGatt.requestMtu()) and emits 509, the payload.
FixLinux
IPeripheral.Mtu on BlueZ reported 512 - an ATT MTU, not the payload every other platform returns. Code that fragmented to peripheral.Mtu was writing 3 bytes over the link’s actual limit on every operation. It now reports 509, matching the contract above.
Feature
BleConstants (in Shiny.BluetoothLE.Common, so it is available to both the client and hosting packages) exposes AttHeaderSize (3), DefaultAttMtu (23), and DefaultPayloadSize (20) for converting between the two units without a magic number - var attMtu = peripheral.Mtu + BleConstants.AttHeaderSize;.
Feature
L2CAP file transfers - upload & download. peripheral.UploadFile(psm, path, ...) and peripheral.DownloadFile(psm, remoteName, localPath, ...) open a channel, move the file, and close it again; UploadFileWithProgress / DownloadFileWithProgress are the Rx flavours. They speak a small framed protocol so both peers agree on the file name and the exact byte count before any body bytes move - which is what makes percent-complete and ETA real on both ends, matching the metrics Shiny.Net.Http reports. Stream overloads are available on both directions, and L2CapTransferOptions tunes buffer size, progress interval, and idle timeout. Pair with IBleHostingManager.OpenL2CapFileServer(...) on the peripheral.
Feature
OpenL2CapChannelAsync(psm, secure, ct) - awaitable form of ICanL2Cap.OpenL2CapChannel that throws NotSupportedException on platforms without L2CAP instead of returning an empty observable. Use it to move several files over one channel: channel.UploadFile(...) / channel.DownloadFile(...) are the same helpers, minus the open/close per file.
Feature
A central can now also serve transfers. channel.ReadFileRequest(...) waits for the peer’s next request and returns an L2CapFileRequest to answer with AcceptUpload / AcceptDownload / Reject. New supporting types L2CapTransferResult (bytes, elapsed, average throughput), L2CapTransferOptions, L2CapTransferError, and L2CapTransferException live in Shiny.BluetoothLE.Common, so the same surface works from the central and hosting sides.
Fix
L2CapChannelExtensions.SendFile no longer risks sending corrupt bytes on slower links. The reusable read buffer was handed straight to Write, which only promises the bytes are queued - so the next chunk could overwrite bytes still in flight, and a short read could trail stale bytes from the previous chunk. Each write now gets its own array.
Feature
L2CAP file server. IBleHostingManager.OpenL2CapFileServer(rootDirectory, secure, configure) publishes a PSM backed by a directory that connected centrals can push files to and pull files from, using the matching UploadFile / DownloadFile helpers in Shiny.BluetoothLE. Configure it with upload/download toggles, MaxUploadSize (refused with TooLarge before a single body byte is read), an overwrite policy, an Authorize hook per request, and OnProgress / OnCompleted / OnError callbacks carrying the peer identifier, file name, and transfer metrics. Peer-supplied names are resolved under the root - absolute paths and ../ traversal are refused before any filesystem access.
Feature
IBleHostingManager.HandleL2CapRequests(secure, onRequest, options, onError) publishes a PSM and hands every inbound L2CapFileRequest to your own handler - for serving from a database, generating content on the fly, or any shape the directory server doesn’t cover. Answer with AcceptUpload / AcceptDownload / Reject; a request your handler leaves unanswered is auto-rejected so the peer is never left hanging.
Fix
L2CapChannelExtensions.SendFile no longer risks sending corrupt bytes on slower links. The reusable read buffer was handed straight to Write, which only promises the bytes are queued - so the next chunk could overwrite bytes still in flight. Each write now gets its own array.
Feature
SSDP/UPnP support. Register services.AddSsdp() and inject ISsdpManager to discover UPnP devices — routers, DLNA media servers, Sonos, Roku, smart TVs. SearchAll(...)/Search(...) for a one-shot sweep, Browse(...) for a live IAsyncEnumerable&lt;SsdpBrowseResult&gt; keyed on UDN, and Publish(...) to advertise your own root device. Supported on iOS, Mac Catalyst, macOS, Android, Windows, Linux, and server .NET.
Feature
ISsdpManager.GetDescription(...) fetches and parses a device’s UPnP description document into UpnpDeviceDescription — friendly name, manufacturer, model, serial, icons, the service list (UpnpService with SCPD/control/event URLs), and embedded devices via Flatten(). Relative URLs are resolved against URLBase or the fetch location, including the classic missing-trailing-slash case, and a URLBase pointing at a different host than the document was fetched from is ignored as stale.
Feature
WS-Discovery support. Register services.AddWsDiscovery() and inject IWsDiscoveryManager to find ONVIF IP cameras, WSD printers and scanners, and Windows machines. Probe/Resolve/Hello/Bye on both the 2005 (ONVIF/Windows) and 2009 (OASIS) profiles as separate datagrams, ProbeOnvifCameras(...) as a shortcut, and Publish(...) to advertise this host as a target service. WsdTarget.GetScopeValue("name") reads ONVIF-style scopes, and PreferredAddress picks a reachable XAddr from the stale and unreachable ones devices routinely advertise.
FeatureiOS
SSDP and WS-Discovery require the com.apple.developer.networking.multicast entitlement on iOS, unlike mDNS. No OS exposes an API for either protocol — Android’s NsdManager and Apple’s Bonjour stack are strictly mDNS/DNS-SD — so both use raw multicast on every platform. Apple grants the entitlement per developer team on request, and it cannot be tested in the Simulator. mDNS is unaffected and still needs no entitlement.
FeatureAndroid
SSDP and WS-Discovery additionally need CHANGE_WIFI_MULTICAST_STATE in the manifest (without it, sends succeed and nothing is ever received over Wi-Fi) and the ACCESS_LOCAL_NETWORK runtime permission when targeting SDK 37 / Android 17 or later. The WifiManager.MulticastLock is acquired and released automatically for the lifetime of a browse or publication — no app code needed.
Feature
New DiscoveryPermissionException is thrown when the OS refuses a multicast operation, with a message naming the exact entitlement, manifest entry, runtime permission, or firewall rule that is missing. Previously — and in most discovery libraries — a missing permission is indistinguishable from an empty network. DiscoveryException is now the base type for all failures, with SsdpException and WsDiscoveryException alongside the existing MdnsException.
Enhancement
The SSDP client tracks UPnP 1.1 BOOTID/CONFIGID, so a ssdp:byebye arriving late from a previous boot session no longer evicts a device that has already come back, and a CONFIGID change signals that a cached description should be re-fetched. Expiry runs on a monotonic clock and max-age is clamped to a sane range, because devices advertise everything from one second to a week.
Enhancement
WS-Discovery Types are parsed as real XML QNames, resolving each prefix against the namespace declarations in scope on the Types element or any ancestor. String-splitting that list — the common shortcut — silently fails to match devices whose prefixes are declared anywhere other than where the parser expects. Scope matching implements the RFC 3986/2396 rule properly (case-insensitive scheme and authority, case-sensitive segment-wise path prefix), plus strcmp0 and uuid; ldap is recognised and deliberately never matches.
Enhancement
Both responders are hardened against reflection and amplification abuse, which is an actively exploited vector for SSDP: they answer only sources on a network this host belongs to, rate limit per source, spread an ssdp:all burst across the requester’s own MX window, and bound every collection parsed off the wire. Device caches are LRU-bounded so a spoofing sender cannot grow them without limit.
Enhancement
UPnP description fetching applies a default-deny policy: http/https only, no credentials in the URL, no redirects, no cookies, a 512KB cap enforced while streaming rather than trusting Content-Length, a 10 second timeout, DTD processing disabled, and a literal-IP host that must match the address the advertisement came from. Override it per call with the urlFilter parameter on GetDescription when a device legitimately advertises a different host. Descriptions are parsed by local element name, ignoring XML namespaces, because shipped devices variously declare none, the wrong one, or vendor extensions.
Chore
The mDNS socket layer was generalised into a shared multicast implementation now used by all three protocols — per-interface group joins, outbound interface reselection per send, SO_REUSEADDR/SO_REUSEPORT, and rejoin on network change. mDNS behaviour is unchanged; multicast TTL is now per-protocol (255 for mDNS as RFC 6762 requires, 2 for SSDP, 1 for WS-Discovery) and the receiving interface is captured per datagram so link-local IPv6 URLs get a zone id.
Feature
New Shiny.Net.Discovery package — cross-platform mDNS/DNS-SD (Bonjour/Zeroconf) service discovery and publishing. Register with services.AddMdns() and inject IMdnsManager to browse the local link (Browse for a live IAsyncEnumerable<MdnsBrowseResult> stream, BrowseOnce for a fixed-window scan), resolve a known instance to its host/port/addresses/TXT records, and publish your own service with Publish(...). Supported on iOS, Mac Catalyst, macOS, Android, Windows, Linux, and server .NET.
FeatureiOS
Apple platforms are backed by NSNetService (Bonjour) rather than raw multicast sockets, so no com.apple.developer.networking.multicast entitlement is required — only NSLocalNetworkUsageDescription and an NSBonjourServices array listing every service type the app browses for or publishes. Browsing silently returns nothing when a type is missing from that array.
FeatureAndroid
Android is backed by NsdManager, so no CHANGE_WIFI_MULTICAST_STATE permission and no WifiManager.MulticastLock are needed — only INTERNET and ACCESS_NETWORK_STATE in the manifest. Resolution uses registerServiceInfoCallback on API 34+ (concurrent resolves) and falls back to a serialised resolveService below it, because the older API only tolerates one resolve at a time.
FeatureWindows
Windows, Linux, macOS console, and server .NET are served by a dependency-free managed responder speaking mDNS directly on UDP 5353. It implements RFC 6762/6763 name compression, probing with conflict-driven renaming, announcements, goodbye packets, TTL expiry, and query answering across every multicast-capable interface, and rejoins the multicast group when the network changes. SO_REUSEADDR/SO_REUSEPORT let it coexist with avahi-daemon, mDNSResponder, or the Windows DNS client — Avahi is not a dependency on Linux.
Feature
MdnsService exposes InstanceName, ServiceType, Domain, HostName, Port, Addresses (IPv4 and IPv6), TxtRecords, FullName, IsResolved, and GetEndPoint(family?). TXT records are read with GetTxt(key) or the parsing GetTxt&lt;T&gt;(key, fallback) for any IParsable&lt;T&gt;.
Feature
Publishing returns an IMdnsPublication whose InstanceName reflects any conflict-driven rename ("My Service""My Service (2)"), so always read the live name back rather than reusing the requested one. Disposing the handle sends a goodbye packet and stops advertising.
Enhancement
Service types are validated against RFC 6763 section 7 up front — MdnsException explains exactly what is wrong with a malformed type rather than failing silently at the socket. Service subtypes (_sub) and domains other than local are explicitly rejected in v1.
BREAKINGFeature
Query() is now a fluent async builder. IContactStore.Query() returns a ContactQuery instead of an IQueryable<Contact>: configure it with Where(ContactField, value, ContactFilterOperation) / Where(Func<Contact,bool>) / Search(text) / Match / OrderBy / ThenBy / Skip / Take, then run it with ToListAsync(ct), FirstOrDefaultAsync(ct) or CountAsync(ct). The native read is awaited off the calling thread, so it is safe to call from the UI thread, and it finally takes a CancellationToken. Rewrite contactStore.Query().Where(c => c.GivenName.Contains("John")).ToList() as await contactStore.Query().Where(ContactField.GivenName, "John").ToListAsync(ct). See Querying.
Feature
Search(text) — one call that matches given name, family name, display name, phone numbers and email addresses (OR), which is what most contact-picker search boxes actually want. The equivalent LINQ || predicate was never translated natively.
Feature
Filtering on organization fieldsContactField.Company, JobTitle and Department are now honoured. Previously an Organization filter was extracted from the expression tree but silently dropped on Android, so it returned unfiltered results.
Enhancement
Field filters are always re-applied in-memory after the native fetch, so a filter a platform can’t translate narrows the result correctly instead of being ignored. Comparisons are consistently case-insensitive across platforms.
FixiOS
A Contains/EndsWith name filter is no longer pushed into the CNContact predicate. That predicate matches on a name-token prefix, so Contains was silently dropping valid matches; iOS now only pushes down StartsWith/Equals, and never when the query is in Match.Any mode.
Chore
The LINQ expression visitor, provider and interpreter are gone — no expression trees are built or walked at query time any more, which is a meaningful win under AOT/trimming.
BREAKINGFeature
Query() is now a fluent async builder. ICalendarStore.Query() returns a CalendarEventQuery instead of an IQueryable<CalendarEvent>: configure it with ForCalendar / From / To / Between / Where(Func<CalendarEvent,bool>) / TitleContains / OrderBy / ThenBy / Skip / Take, then run it with ToListAsync(ct), FirstOrDefaultAsync(ct) or CountAsync(ct). This removes the whole class of “blocking enumeration on the UI thread” bugs — the native read is awaited off the calling thread — and it takes a CancellationToken, which the IQueryable never could. Rewrite store.Query().Where(e => e.CalendarId == id && e.Start >= from && e.End <= to).OrderBy(e => e.Start).ToList() as await store.Query().ForCalendar(id).Between(from, to).OrderBy(CalendarEventSortField.Start).ToListAsync(ct). See Querying.
Enhancement
Sorting (OrderBy/ThenBy over CalendarEventSortField.Start/End/Title, each with a descending flag) is now built into the query rather than something you bolt on afterwards.
Chore
The LINQ expression visitor, provider and interpreter are gone — no expression trees are built or walked at query time any more, which is a meaningful win under AOT/trimming.
Feature
Per-calendar AI tool access. ICalendarAIToolBuilder gained AddCalendar(string calendarId, CalendarAICapabilities) and AddCalendars(IEnumerable<string>, CalendarAICapabilities), so the agent’s allow-list can differ per calendar — read-only on one, read/write on another — while AddCalendar(capabilities) still sets the global default for every calendar. A per-calendar entry replaces the global set, so it can widen or narrow access, and CalendarAICapabilities.None hides a calendar from the agent entirely. The tools enforce it on every call: list_calendars returns only allowed calendars (each with an allowedOperations array), search_events searches only readable ones, and get_event / create_event / update_event / delete_event refuse calendars outside the filter. See AI Tools.
Enhancement
New AddCalendarAITools(Action<IServiceProvider, ICalendarAIToolBuilder>) overload — the callback runs when CalendarAITools is first resolved, so the allowed calendar ids can come from your own services (a settings store, the user’s calendar picker) rather than having to be known at registration time.

Initial Release

Feature
Shiny.Calendar — new cross-platform calendar library. ICalendarStore provides full CRUD on calendars and events plus a LINQ Query() over events. Register with builder.Services.AddCalendarStore().
FeatureiOS
EventKit backend for iOS, Mac Catalyst, and macOS, with iOS 17 full / write-only access via CalendarAccessType.
FeatureAndroid
CalendarContract backend with READ_CALENDAR / WRITE_CALENDAR permissions and full event CRUD.
FeatureWindows
Best-effort AppointmentStore backend — reads/queries all calendars; create/update/delete are supported inside an app-owned calendar (system-calendar writes throw).
Feature
LINQ queryingIQueryable<CalendarEvent> pushes calendar id and the Start/End window down to the native fetch, with an exact in-memory pass for everything else.
Feature
Rich event model — attendees, reminders, availability, URL, and read-only recurrence/organizer. EventAttendee.ResolveContact(IContactStore) bridges an attendee to a device contact by email.
Feature
Shiny.Calendar.Extensions.AI — exposes ICalendarStore as Microsoft.Extensions.AI tools with per-operation allow-listing (Read / Create / Update / Delete). Generates list_calendars, search_events, get_event, create_event, update_event, delete_event. AOT-compatible. See AI Tools.
Chore
Recurrence is surfaced read-only; on Apple, attendee writes are ignored (EventKit limitation).
FixMac Catalyst
Document the com.apple.security.personal-information.calendars sandbox entitlement, and add it to the MAUI sample. Mac Catalyst enables the App Sandbox by default, so without it RequestAccess returned Denied and no prompt ever appeared. See Permissions.
Feature
DeleteEvent gained a deleteSeries flag — DeleteEvent(string eventId, bool deleteSeries = false, CancellationToken ct = default). For a recurring event, false removes only that occurrence and true removes it and all future ones; it is ignored for non-recurring events. Previously every delete was hardcoded to the single occurrence on Apple and to the whole series on Android. The delete_event AI tool takes the flag too. See Deleting a Recurring Event.
Fix
Calendar pills in the MAUI sample’s calendar list are now selectable and actually filter the event list — they were previously non-interactive, with no SelectionMode, no selection binding, and no filter state in the view model.
BREAKINGFix
The Shiny.Locations.Extensions static class has been renamed to Shiny.Locations.LocationExtensions. A type named Shiny.Locations.Extensions is indistinguishable from the Shiny.Locations.Extensions.AI namespace once both assemblies are referenced, and the compiler reports that as CS0434 — an error, not a warning — in the consuming project, forcing an extern alias workaround. Every member is an extension method (GetCurrentPosition, GetLastReadingOrCurrentPosition, IsListening, IsInsideRegion, TryStartMonitoring, IsPositionInside), so call sites are unaffected; only code that named the class explicitly needs updating.
BREAKINGEnhancement
Shiny.Locations.Extensions.AI registration moved to the Shiny namespace. AddLocationAITool() now lives on Shiny.LocationsAiServiceCollectionExtensions instead of Shiny.Locations.Extensions.AI.ServiceCollectionExtensions, so the using Shiny.Locations.Extensions.AI; in your MauiProgram is no longer needed — Shiny is a global using from Shiny.Core. This also removes the CS0435 suppression the package previously shipped to work around the namespace/type collision.
BREAKINGEnhancement
Shiny.Contacts.Extensions.AI registration moved to the Shiny namespace. AddContactsAITools() now lives on Shiny.ContactsAiServiceCollectionExtensions instead of Shiny.Contacts.Extensions.AI.ServiceCollectionExtensions, so the using Shiny.Contacts.Extensions.AI; in your MauiProgram is no longer needed — Shiny is a global using from Shiny.Core. The builder/capability types (ContactAICapabilities, ContactAITools) stay where they are; only the registration entry point moved.
BREAKINGEnhancement
Shiny.Notifications.Extensions.AI registration moved to the Shiny namespace. AddNotificationAITools() now lives on Shiny.NotificationAiServiceCollectionExtensions instead of Shiny.Notifications.Extensions.AI.ServiceCollectionExtensions, so the using Shiny.Notifications.Extensions.AI; in your MauiProgram is no longer needed — Shiny is a global using from Shiny.Core. The builder/capability types (ReminderAICapabilities, NotificationAITools) stay where they are; only the registration entry point moved.
FixiOS
A disconnect that races an in-flight GATT operation no longer permanently deadlocks the BLE operation queue. CoreBluetooth delegate callbacks are the only thing that completes a queued operation, and a dead peripheral never fires them again — so an operation parked on one (most visibly a WriteCharacteristic(withResponse: false) waiting on canSendWriteWithoutResponse flow control) held the operation lock forever, blocking every subsequent operation across all peripherals until the app was restarted. Every queued Apple operation — service/characteristic/descriptor discovery, reads, both write modes, and ReadRssi — now aborts with a BleException when the peripheral disconnects mid-operation, releasing the lock so reconnect and retry work normally. Applies to iOS, Mac Catalyst, and macOS.
FixAndroid
The same disconnect-mid-operation deadlock is fixed on Android. Once the link drops, the BluetoothGatt client is closed and its callbacks never fire again, so a parked read, write-with-response, descriptor read/write (including the CCCD write that re-arms notifications on reconnect), service discovery, or ReadRssi held the operation lock indefinitely. These now abort with a BleException on disconnect — including the explicit disconnect path, where Gatt.Close() suppresses the framework’s own state-change callback. Write-without-response was never affected on Android; it does not wait on flow control.
FixAndroid
AddHttpTransfers<TDelegate>() now registers the platform HttpTransferManager on Android instead of the in-process HttpClientHttpTransferManager, so transfers run inside the dataSync foreground service (HttpTransferService) under OS ownership. This fixes two field issues: transfers stalling or being lost when the app is backgrounded or the process is killed (the service now redelivers its intent and re-arms whenever pending transfers exist), and a queue wedge where a transfer queued while the loop was draining stuck in Pending until the next Queue() call. Windows keeps the managed manager; iOS/Mac Catalyst are unchanged. Note: Android 14+ requires FOREGROUND_SERVICE_DATA_SYNC in the app manifest or the service start throws SecurityException.
EnhancementAndroid
New opt-in HttpTransferService.UseShortService = true promotes the Android foreground service as FOREGROUND_SERVICE_TYPE_SHORT_SERVICE (Android 14+) instead of dataSync. This drops the FOREGROUND_SERVICE_DATA_SYNC permission and the Google Play policy declaration dataSync requires, at the cost of a ~3-minute promotion window — a fit for burst-transfer apps and a bad fit for long downloads, hence opt-in. The default is unchanged, and Android 13-and-lower devices ignore the flag.
FixAndroid
HttpTransferService now implements Service.onTimeout (both the API 34 shortService overload and the API 35 typed one). Android 15 put dataSync on a ~6-hour daily budget delivered through the same callback; an unhandled timeout previously produced an ANR (“A foreground service of … did not stop within its timeout”). The service now logs and stops cleanly on timeout, leaving pending transfers in the repository so the existing machinery re-arms the service on the next Queue() or app start.
Feature
Shiny.Locations.Extensions.AI — new package exposing read-only GPS as Microsoft.Extensions.AI tool functions for LLM agents. Register with a single AddLocationAITool(); generates get_current_location, get_distance_to, and estimate_travel_time (great-circle distance + rough ETA). AOT-compatible. See AI Tools.
BREAKINGFeature
Shiny.Maui.ContactStore is now Shiny.Contacts
BREAKINGFeature
Contacts now has a dependency on Shiny.Core which allows it to work without MAUI
Feature
Shiny.Contacts.Extensions.AI — new package exposing IContactStore as Microsoft.Extensions.AI tool functions for LLM agents. Opt-in read/write allow-list via AddContactsAITools(b => b.AddContacts(ContactAICapabilities.ReadWrite)); generates search_contacts, get_contact, create_contact, update_contact, and delete_contact. AOT-compatible. See AI Tools.
Fix
Bulk reads no longer load full-resolution photosGetAll() and Query() now populate only Thumbnail; decoding every contact’s full Photo into memory at once could OOM/jetsam-kill the app on a real device with many photo contacts. The full Photo is still available per-contact via GetById(). See Photos vs Thumbnails.
Feature
Shiny.Notifications.Extensions.AI — new package exposing local notifications as reminder-framed Microsoft.Extensions.AI tool functions for LLM agents. Opt-in read/write allow-list via AddNotificationAITools(b => b.AddReminders(ReminderAICapabilities.ReadWrite)); generates list_reminders, create_reminder (one-time or daily), and cancel_reminder. AOT-compatible. See AI Tools.
FixAndroid
Scheduled notifications no longer silently fail to fire when exact alarm permission is not granted. On Android 12+ Shiny now falls back to an inexact alarm (which may be slightly delayed by the OS) instead of throwing a SecurityException when SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM access is unavailable. Request AccessRequestFlags.TimeSensitivity for exact, on-time delivery.
FixAndroid
Corrected the exact-alarm manifest guidance. Capping SCHEDULE_EXACT_ALARM with android:maxSdkVersion="32" without also declaring USE_EXACT_ALARM leaves an app with no exact-alarm permission on Android 13+ (API 33+) - the “Alarms & reminders” toggle appears greyed out and unchecked. Most apps should now declare SCHEDULE_EXACT_ALARM uncapped; only genuine alarm/reminder apps use the capped + USE_EXACT_ALARM pairing.
FixAndroid
Scheduled, repeating, and geofence notifications now appear in GetPendingNotifications() (and can be cancelled by id). They were being persisted under the wrong entity type, so the pending list always came back empty and Cancel(id)/GetNotification(id) could not find them. Repeating notifications now also persist their recalculated next-fire date after each alarm fires, so they stay in the pending list across occurrences instead of disappearing.
FixAndroid
Fixed multiple scheduled/repeating notifications colliding on a single alarm. The alarm PendingIntent now uses the notification id as its request code - previously a shared request code meant a newly scheduled notification overwrote earlier pending alarms, Cancel could cancel the wrong alarm, and a fired alarm could resolve the wrong notification.
FixAndroid
Fixed a startup deadlock (Android ANR) that occurred when an app registered an IHttpTransferDelegate — such as one derived from the HttpTransferDelegate base class — that depends on IHttpTransferManager. The managed HttpClientHttpTransferProcess eagerly injected IEnumerable<IHttpTransferDelegate> in its constructor, forming a circular dependency (HttpClientHttpTransferManager → HttpClientHttpTransferProcess → delegates → IHttpTransferManager) that the DI container’s AddSingletonAsImplementedInterfaces factory forwarders hid from cycle detection, deadlocking the singleton root-cache lock during host build. Delegates are now resolved lazily from IServiceProvider at execution time (matching the Apple implementation), breaking the cycle. No public API changes.
FixAndroid
Fixed a startup deadlock (Android ANR) that occurred when an app registered an IDataSyncDelegate that depends on IDataSyncManager. SyncInboxProcessor and the managed HttpClientDataSyncProcess eagerly injected IEnumerable<IDataSyncDelegate> in their constructors, and the singleton DataSyncManager / HttpClientDataSyncManager (registered via AddSingletonAsImplementedInterfaces) eagerly constructed those processors — forming a circular dependency (DataSyncManager → processor → delegates → IDataSyncManager) that the container’s factory forwarders hid from cycle detection, deadlocking the singleton root-cache lock during host build. Delegates are now resolved lazily from IServiceProvider at execution time (matching the Apple NSURLSession implementation), breaking the cycle. Affects Android and the cross-platform (Windows / Linux / macOS / base .NET / Blazor WASM) HttpClient path. No public API changes.
FixAndroid
NotifyCharacteristic no longer drops the first notification(s) for peripherals that stream data the instant the subscription is enabled. The notification listener is now wired up before the CCCD descriptor write turns the peripheral on (it was previously hooked only after the write was acknowledged, leaving a window where the earliest OnCharacteristicChanged callbacks were lost).
FixiOS
NotifyCharacteristic now subscribes to characteristic updates before calling SetNotifyValue(true), closing the same notify-before-listen race on Apple platforms (iOS, Mac Catalyst, macOS).
FixiOS
FCM registration no longer hangs. The Core and Cloud Messaging Swift shims shipped in separate xcframeworks, and because the Firebase SPM products are statically linked, each framework embedded its own copy of FirebaseCore - so FirebaseApp.configure() configured one FIRApp while the token request read a second, unconfigured one. IPushManager.RequestAccess() awaited a native completion block that never fired: no token, no exception, no IPushDelegate.OnNewToken, and only an objc[...]: Class FIRApp is implemented in both ... warning at startup as a clue. All three shims now build into a single ShinyFirebase framework with one embedded FirebaseCore. Thanks to @ThomasManson for the diagnosis
BREAKINGiOS
The Shiny.Firebase.Analytics.iOS.Binding and Shiny.Firebase.Messaging.iOS.Binding packages are replaced by a single Shiny.Firebase.iOS.Binding. Shiny.Push.FirebaseMessaging picks it up automatically - no change is needed unless you referenced a binding package directly, in which case swap the reference and change the namespace to Shiny.Firebase.iOS.Binding. The managed type names (FirebaseApplication, FirebaseMessaging, FirebaseAnalytics) and their members are unchanged
FixiOS
FirebaseAnalytics.SetUserProperty was bound to the wrong Objective-C selector and would have thrown unrecognized selector at runtime
EnhancementiOS
The native messaging shim now fails fast instead of hanging - when Firebase has not been configured, RequestAccess() throws Firebase has not been configured rather than awaiting a completion block that will never fire
Feature
IHttpTransferManager now exposes Pause(identifier) and Resume(identifier). Pause stops a transfer without cancelling it - the transfer stays queued and reports HttpTransferState.Paused. Downloads resume from where they left off (HTTP Range on the managed platforms, native NSUrlSessionTask.Suspend()/Resume() on iOS/Mac Catalyst). Uploads are stopped but not resumable, so resuming an upload restarts it from the beginning. A user-paused transfer is no longer auto-resumed on app relaunch.
FeatureBlazor
Pause/Resume on Blazor WASM is best-effort: a queued transfer is marked paused so the Service Worker drain skips it, and resume re-queues it. An already in-flight Service Worker fetch() cannot be aborted (it runs to completion), and downloads are not resumable (whole-Blob fetch), so a resumed download restarts.
Enhancement
Resumable downloads are confirmed across all managed platforms (Android, Windows, Linux, macOS, plain .NET) via HTTP Range (206 Partial ContentFileMode.Append); iOS resumes natively through the background NSUrlSession. Partial files are preserved on pause and only deleted on cancel.
Fix
Shiny.Push.FirebaseMessaging is now built with IsAotCompatible and the generic AddPushFirebaseMessaging<TPushDelegate> overload forwards the DynamicallyAccessedMembers annotation its registration requires. Without it the trimmer was free to remove the delegate’s constructor and interface map, so a trimmed app could fail to resolve IPushDelegate at startup with no build-time warning.
FixAndroid
Scanning now discovers devices again. A previous change forced setLegacy(false) alone on Android 8+, which on most chipsets suppresses the legacy advertisements that virtually all BLE peripherals send. Scans now report both legacy AND Bluetooth 5 extended advertisements automatically, by pairing setLegacy(false) with all-PHY scanning and only enabling it when the chipset reports IsLeExtendedAdvertisingSupported (otherwise falling back to a legacy scan). Force a legacy-only scan with new AndroidScanConfig(IncludeExtendedAdvertisements: false).
FixAndroid
RequestAccess() no longer crash the app ~5 seconds after the OS permission dialog appears. The internal 5-second guard was incorrectly timing the user’s interaction with the dialog; it now only bounds the wait for an Activity to become available, so the user may take as long as they like to respond.
FixAndroid
Fixed IGeofenceManager is not registered exception when using Shiny.Notifications as a standalone package (without Shiny.Locations) - AddNotifications() now registers the geofence services and NotificationGeofenceDelegate needed for geofence-triggered local notifications
FixAndroid
Fixed No JsonTypeInfo registered for type 'Shiny.Notifications.Notification' thrown from Send - the base Notification/Channel types are now registered in the source-generated JSON context (the Android notification intent serializes the base Notification). List and array variants of all notification types are registered as well.
FixWindows
Unpackaged apps no longer throw/log an error from RequestAccess/GetCurrentAccess - ToastNotifier.Setting is unsupported without package identity, so access is now reported as available instead of failing
Fix
Shiny.Push.AzureNotificationHubs no longer throws No JsonTypeInfo registered for type 'System.String[]' when reading or writing RegisteredTags - the package now ships a source-generated JSON context registering the string[]/List<string> used by its key/value store persistence.
Feature
L2CapChannelExtensions.SendFile(...) — new file-transfer helper on top of an open L2CapChannel with HTTP-transfer-style progress metrics (bytes-per-second, percent-complete, estimated time remaining). Overloads accept either a file path (length auto-detected) or an arbitrary Stream with an optional totalBytes. Progress callbacks fire ~every 2s plus a final 100% emission on completion. The new Shiny.BluetoothLE.TransferProgress record mirrors Shiny.Net.Http.TransferProgress so consumers have an identical mental model across HTTP and L2CAP transfers. Lives in Shiny.BluetoothLE.Common, shared with the hosting library.
Feature
L2CAP CoC central-role support shipped via the optional ICanL2Cap capability on IPeripheral. Call peripheral.OpenL2CapChannel(psm, secure) (or the safe TryOpenL2CapChannel(...) extension on the base IPeripheral) to open a streaming channel to a peripheral that has published a PSM. Supported on iOS, Mac Catalyst, macOS (CoreBluetooth CBPeripheral.OpenL2CapChannel), Android API 29+ (BluetoothDevice.CreateL2capChannel / CreateInsecureL2capChannel), and Linux (BlueZ — raw AF_BLUETOOTH / BTPROTO_L2CAP / SOCK_SEQPACKET socket; LE dynamic PSMs ≥ 0x80 do not need CAP_NET_RAW). On Apple platforms the secure flag is ignored — security is determined by how the peripheral published the channel. On Linux the flag toggles BT_SECURITY_LOW/MEDIUM via setsockopt(SOL_BLUETOOTH, BT_SECURITY).
Enhancement
The public L2CapChannel record moved into Shiny.BluetoothLE.Common (namespace Shiny.BluetoothLE) so both central and hosting libraries share one type. The record now implements IDisposable with an optional OnDispose hook for closing streams and disposing sockets.
FixAndroid
Shiny.BluetoothLE.Extensions.ListenForData(BluetoothSocket) now reads from socket.InputStream (was incorrectly reading OutputStream) and emits a right-sized copy of each chunk instead of the full 8 KB buffer. The observable now completes on EOF and surfaces read errors via OnError.
FixiOS
Shiny.BluetoothLE.Extensions.ListenForData(NSInputStream) now drains all bytes available per HasBytesAvailable event, emits a right-sized copy per read (was leaking the full 8 KB shared buffer to every subscriber), and completes on NSStreamEvent.EndEncountered.
Feature
macOS support added via CoreBluetooth (central role) - Shiny.BluetoothLE
Feature
Linux support added via BlueZ / D-Bus (central role) - new Shiny.BluetoothLE.Linux package
Feature
Blazor WebAssembly (Web) support added via the browser Web Bluetooth API - new Shiny.BluetoothLE.Blazor package. Central role only; scans require a user gesture, HTTPS, and a Chromium-based browser
FixWindows
Fix BLE state cleanup after disconnect and reconnect - properly releases GATT resources and disposes stale peripherals
FixAndroid
Fix the classic “status 133 after a few reconnects” trap — Connect() now closes any prior BluetoothGatt before opening a new client, so reconnect loops no longer leak GATT clients into Android’s per-app limit.
FixAndroid
Connection state and connection-failure observables are now replay-safe (BehaviorSubject / time-windowed ReplaySubject), so subscribers that hook up immediately after calling Connect() no longer miss the resulting state change or failure.
FixAndroid
OnConnectionStateChange now dispatches subscriber notifications off the single-threaded GATT binder callback thread, removing a class of deadlocks where awaiting subscribers blocked further BLE callbacks.
FixAndroid
Notifier teardown re-resolves the characteristic against the current BluetoothGatt instead of a captured (possibly closed) reference, preventing spurious status 133 errors on the next operation after a disconnect/reconnect cycle.
FixAndroid
Starting a scan no longer evicts peripherals that are currently in the Connecting state.
FixiOS
IBleManager.GetKnownPeripheral(uuid) now calls CoreBluetooth’s RetrievePeripheralsWithIdentifiers, so callers can reconnect to a previously-paired device by UUID after a process restart without first running a scan.
FixiOS
IBleManager.GetConnectedPeripherals() now seeds from RetrieveConnectedPeripherals so devices connected by other apps or restored sessions are visible.
FixiOS
Auto-reconnect now issues CancelPeripheralConnection before each retry (iOS otherwise holds the previous pending connection slot) and additionally retries on FailedToConnectPeripheral, fixing cold-start failures that previously never retried because no Disconnected event was emitted.
FixWindows
Service and characteristic lookups on the hot path now use BluetoothCacheMode.Uncached, so a reconnect always re-discovers fresh GattDeviceService / GattCharacteristic handles instead of silently operating on dead, OS-cached ones.
FixWindows
Peripheral connections now acquire a GattSession with MaintainConnection = true, so the OS keeps the LE link up across idle periods and automatically re-establishes it when a device returns in range — eliminating “the connection dropped while idle” symptoms.
FixWindows
IPeripheral references now survive a disconnect/reconnect cycle. Previously the manager replaced the wrapper instance on every reconnect, leaving any caller-held reference dead with “Device is disposed” errors. The wrapper now refreshes its underlying BluetoothLEDevice in place.
FixWindows
A transient ConnectionStatus = Disconnected dip immediately after a successful service discovery no longer cancels the connect attempt — the connect now succeeds, and a real subsequent disconnect is handled by the normal ConnectionStatusChanged path.
BREAKING
Managed characteristic pattern removed for AOT compliance. The BleGattCharacteristic base class, [BleGattCharacteristic] attribute, AddBleHostedCharacteristic<T>(), AttachRegisteredServices(), and DetachRegisteredServices() are all gone. Compose GATT services in code via IBleHostingManager.AddService(uuid, primary, sb => ...) — typically inside a class registered as an IShinyStartupTask. See the GATT Service page for the new pattern.
Enhancement
Rx removed from Shiny.BluetoothLE.Hosting’s manager surface. IBleHostingManager no longer exposes any IObservable<T> members — characteristic write/read/notification hooks are async Task-based as before. The L2CapChannel record still uses Rx for its bytes-in/bytes-out streams (shared with Shiny.BluetoothLE client).
Feature
L2CapChannelExtensions.SendFile(...) — new file-transfer helper on top of an open L2CapChannel with HTTP-transfer-style progress metrics (bytes-per-second, percent-complete, estimated time remaining). Useful for streaming firmware blobs and other large payloads to a connected central. Overloads accept either a file path (length auto-detected) or an arbitrary Stream with an optional totalBytes. Progress callbacks fire ~every 2s plus a final 100% emission on completion. The supporting Shiny.BluetoothLE.TransferProgress record mirrors Shiny.Net.Http.TransferProgress. Lives in Shiny.BluetoothLE.Common, shared with the central library.
FixAndroid
BleHostingManager now takes AndroidPlatform through a primary constructor and eagerly initializes its GattServerContext. Previously the context field was declared readonly but never assigned, so every call into the Android hosting manager (advertising, GATT services, request-access, beacon) would have NREd at runtime once the DI container resolved the manager via its compiler-generated parameterless constructor.
Feature
L2CAP CoC peripheral hosting shipped — IBleHostingManager.OpenL2Cap(bool secure, Action<L2CapChannel> onOpen) publishes a PSM and invokes the callback for every accepted central connection. Each L2CapChannel exposes Func<byte[], IObservable<Unit>> Write and IObservable<byte[]> DataReceived, and disposes cleanly via the channel’s IDisposable. Dispose the returned L2CapInstance to stop accepting and release the PSM. Implemented on iOS, Mac Catalyst, macOS (CoreBluetooth CBPeripheralManager.PublishL2CapChannel), Android API 29+ (BluetoothAdapter.ListenUsing[Insecure]L2capChannel), and Linux (BlueZ — raw AF_BLUETOOTH socket with kernel-assigned dynamic PSM ≥ 0x80 and a background accept loop). Windows hosting throws NotSupportedException from OpenL2Cap — WinRT exposes no LE CoC surface.
Enhancement
The public L2CapChannel record moved into Shiny.BluetoothLE.Common (namespace Shiny.BluetoothLE) so both hosting and central libraries share a single type. It now also implements IDisposable with an optional OnDispose hook for platform cleanup (closing streams, releasing sockets).
Feature
macOS support added - peripheral / GATT server hosting via CoreBluetooth
Feature
Linux support added via BlueZ / D-Bus - new Shiny.BluetoothLE.Hosting.Linux package
BREAKING
Rx removed. IGpsManager.WhenReading() and IMotionActivityManager.WhenReading() are gone. Use the new C# events GpsReadingReceived (on IGpsManager) and MotionActivityReadingReceived (on IMotionActivityManager) for foreground updates. Delegates remain the recommended way to process readings while backgrounded.
BREAKING
The Type-based AddGeofencing(Type delegateType) and AddGpsDirectGeofencing(Type delegateType) overloads have been removed. Use the generic AddGeofencing<TDelegate>() / AddGpsDirectGeofencing<TDelegate>() instead.
Enhancement
Geofence delegate and manager registration now flows through AddSingletonAsImplementedInterfaces, and AddGpsDirectGeofencing only registers a manager when one isn’t already present - so it composes cleanly when another module (e.g. Shiny.Notifications) also adds geofencing.
Feature
Motion activity recognition support. Detect walking, running, cycling, automotive, and stationary states using IMotionActivityManager with AddMotionActivity() registration.
Feature
New IMotionActivityDelegate for background motion activity processing.
FeatureiOS
Motion activity powered by CMMotionActivityManager. Requires NSMotionUsageDescription in Info.plist.
FeatureAndroid
Motion activity powered by Google Play Services Activity Recognition API. Silently no-ops if Play Services are unavailable.
Feature
Stationary detection now built into all GPS providers. GpsReading.IsStationary is set automatically on Android, iOS legacy, and iOS 18+ before readings reach delegates or the observable stream.
EnhancementAndroid
AndroidGpsRequest now exposes StationaryMetersThreshold and StationarySecondsThreshold to configure stationary detection sensitivity.
EnhancementiOS
AppleGpsRequest now exposes StationaryMetersThreshold and StationarySecondsThreshold for legacy iOS stationary detection. iOS 18+ continues to use native CLLocationUpdater stationary detection.
BREAKINGChore
Stationary detection removed from GpsDelegate. The IsStationary, DetectStationary, StationaryMetersThreshold, and StationarySecondsThreshold properties have been removed. Use GpsReading.IsStationary instead.
FeatureBlazor
New Shiny.Locations.Blazor package brings foreground GPS to Blazor WebAssembly via navigator.geolocation. Register with builder.Services.AddGps() and inject IGpsManager like any other platform. Background GPS and geofencing are not supported by browsers and remain unavailable on web.
Feature
GpsDelegate now supports MaximumDistance and MaximumTime filters that act as safety nets — if either maximum threshold is crossed, OnGpsReading fires immediately regardless of minimum conditions.
BREAKINGChore
GpsDelegate minimum filters (MinimumDistance and MinimumTime) now use AND logic — both conditions must be met before OnGpsReading fires. Previously, either condition passing would trigger the reading.
ChoreAndroid
Xamarin.AndroidX.Work.Runtime bumped to 2.11.2.1 (WorkManager 2.11.2). No API changes on the Shiny side; the binding pulls Room.Runtime 2.8.4.3 and Lifecycle.* 2.11.0.1 transitively.
BREAKINGEnhancement
JobRegistration.Identifier removed — the job’s CLR Type is now the unique identifier. The WithIdentifier(...) fluent extension is gone. Log messages now reference JobType.FullName. Migrate by deleting .WithIdentifier("...") calls; the registration’s identity is implicit in the type you registered.
BREAKINGEnhancement
IJobManager.RunJobAsTask(string, CancellationToken) replaced by IJobManager.RunJob(Type jobType, CancellationToken). The job is looked up by its registered CLR type and runs normally (inline). Migrate mgr.RunJobAsTask("MyJob")mgr.RunJob(typeof(MyJob)).
BREAKING
IJobManager.RunTask(...) removed, along with the RunJob(..., runAsTask: true) overload and the protected RunAsTask platform template. These wrapped runs in iOS BeginBackgroundTask / Android partial wake-locks for extended execution, which caused a number of hard-to-diagnose native issues (crashes on overrun, disposed completions). Jobs now always run normally. Migrate RunJob(typeof(MyJob), runAsTask: true)RunJob(typeof(MyJob)); for ad-hoc work previously run via RunTask, register it as a normal IJob and call RunJob, or just await your own method.
EnhancementAndroid
The WAKE_LOCK permission is no longer used or needed by Shiny.Jobs — the only consumer was the now-removed wake-lock task wrapping.
Feature
Plain .NET (base TFM) support added — Shiny.Jobs now ships an in-process managed JobManager for Linux, macOS, Blazor WebAssembly, console, and any other non-iOS/Android .NET host. Driven by a recurring timer (default 30s, configurable via JobManager.Interval, min 15s / max 5m). Honours the same BatteryNotLow, DeviceCharging, and RequiredInternetAccess constraints as the native platforms and auto-registers a default JSON filesystem repository under {LocalApplicationData}/Shiny. Jobs only execute while the host process is alive — there is no OS-level scheduler on these targets.
FeatureWindows
Windows support — COM-activated in-process background tasks. Call ShinyJobsBackgroundTask.RegisterComServer() once in your App constructor before any trigger registration, and declare a matching windows.comServer extension in your appx manifest.
Feature
Blazor WebAssembly (Web) support added via the base Shiny.Jobs package (no separate meta-package). Reference Shiny.Jobs directly and bring in Shiny.Core.Blazor for IBattery/IConnectivity; optionally add Shiny.Extensions.Stores.Web to persist job state to browser localStorage instead of the default filesystem repository. The in-process scheduler runs while the tab is open. Note: Service Worker / Periodic Background Sync cannot invoke C# jobs because the SW has no access to the Blazor WASM runtime — for background HTTP specifically, use Shiny.Net.Http.Blazor instead.
BREAKING
Rx removed from Shiny.Jobs. The internal driver and lifecycle task no longer depend on System.Reactive. There were no public observable members on IJobManager; this is mostly a transitive dependency cleanup.
FixiOS
Fixed a native SIGSEGV crash when a BGTaskScheduler job ran past its allotted time. The expiration handler now marks the task complete immediately (instead of only after every job finished), BGTask.SetTaskCompleted is guarded to fire exactly once, and the per-run CancellationTokenSource is no longer disposed while iOS still holds a native reference to its cancel handler.
FixiOS
JobManager.Start() no longer iterates an empty registration set on iOS/Mac Catalyst — the Apple JobManager constructor now receives the JobRegistrar directly, so background task scheduling via BGTaskScheduler happens against the real registered jobs at host boot.
FixAndroid
JobManager.Start() no longer iterates an empty registration set on Android — the Android JobManager constructor now receives the JobRegistrar directly, so WorkManager periodic categories are enqueued with the real registered jobs at host boot.
FixWindows
JobManager.Start() no longer iterates an empty registration set on Windows — the Windows JobManager constructor now receives the JobRegistrar directly, so the COM-activated background task categories are registered with the real jobs at host boot.
Enhancement
AbstractJobManager now treats JobRegistrar.Jobs as the single source of truth. The duplicate internal registrations dictionary and the AddRegistrations lazy-hydration method are gone, and GetJobs() / GetJobsByCategory() read straight from the registrar. JobLifecycleTask no longer needs the registrar injected.
BREAKINGEnhancement
Removed the empty Shiny.Jobs.Blazor meta-package. It contained no code — just project references. Consumers should reference Shiny.Jobs + Shiny.Core.Blazor (and optionally Shiny.Extensions.Stores.Web) directly.
BREAKINGEnhancement
Restructured job scheduling API - Register/Cancel replaced with explicit RegisterPlatformJob, UnRegisterPlatformJob, UnRegisterAllPlatformJobs, and GetPlatformJobs
Enhancement
JobLifecycleTask now manages registered jobs via DI and re-registers on startup
EnhancementAndroid
Improved ShinyJobWorker implementation
Feature
macOS support added - local notifications via UNUserNotificationCenter
Feature
Windows support added - local notifications via ToastNotificationManager (channels are limited compared to mobile)
Feature
Linux support added via new Shiny.Notifications.Linux package. Delivered through the freedesktop org.freedesktop.Notifications D-Bus service (GNOME, KDE, etc.). Scheduled notifications are tracked in-process only — the host must be running for them to fire, since there is no OS-level scheduler on Linux.
FixAndroid
Request SCHEDULE_EXACT_ALARM properly via AlarmManager and system settings intent instead of treating it as a runtime permission
Feature
macOS support added via APNs - Shiny.Push
Feature
Windows support added via Windows Notification Service (WNS) - Shiny.Push
Feature
Blazor WebAssembly (Web) support added via the Web Push (VAPID) standard - new Shiny.Push.Blazor package. Requires HTTPS, a VAPID key pair, and a service worker; Safari requires the user to install the PWA before subscribing
FeatureiOS
Firebase Cloud Messaging on iOS via native bindings - new Shiny.Push.FirebaseMessaging package. Exchanges the APNs token for an FCM registration token and maps tags to FCM topic subscriptions through IPushTagSupport; Android continues to use the built-in Shiny.Push Firebase support
Enhancement
The managed transfer loop now obtains its HttpClient from IHttpClientFactory via a named client (HttpClientHttpTransferProcess.HttpClientName = "Shiny.Net.Http") instead of constructing one directly - consistent with Shiny.Data.Sync. Apps can configure it (timeouts, default headers, primary handler, Polly, etc.) with services.AddHttpClient("Shiny.Net.Http").ConfigureHttpClient(...) after registering transfers. Applies to Android, Windows, Linux, macOS, and plain .NET (iOS/Mac Catalyst use NSUrlSession).
FixiOS
Background NSUrlSession is no longer nulled out when the in-flight transfer count reaches zero. Dropping the reference without invalidating could lead to a duplicate-identifier crash (A background URLSession with identifier X already exists!) the next time a transfer was queued, or silently swap the delegate instance the system held.
FixiOS
IHandleEventsForBackgroundUrl.Handle now compares the incoming sessionIdentifier against the manager’s session identifier (returning false on mismatch) and forces the NSUrlSession to materialize before storing the completion handler, so the delegate is wired up before the OS redelivers background events after a launch.
BREAKING
Rx removed. IHttpTransferManager no longer exposes WhenUpdateReceived(), WatchCount(), or any other IObservable<T> members. Subscribe to the new C# events UpdateReceived and CountChanged instead. WatchTransfer(identifier) now returns Task<HttpTransferResult> directly (await it, no Subscribe). The HttpClient.Upload/Download foreground helpers return Task and accept an Action<TransferProgress>? onProgress callback.
Enhancement
Non-iOS HTTP transfer processes consolidated into a single managed HttpClientHttpTransferProcess. Android, Windows, Linux, macOS, and plain .NET now share one driver; the platform-specific Android HttpTransferProcess and Windows HttpTransferProcess have been removed.
Feature
Plain .NET (base TFM) support — Shiny.Net.Http now ships a managed HttpClientHttpTransferManager for Linux, macOS, and any non-iOS/Android/Windows .NET host. Backed by HttpClient + IConnectivity, the loop wakes immediately on connectivity changes and supports resumable downloads via HTTP Range requests (206 Partial ContentFileMode.Append). Uploads always restart on failure. A default JSON filesystem repository is registered automatically so queued transfers survive process restarts. Cancelled downloads clean up partial files on disk. Register with services.AddHttpClientTransfers<TDelegate>(), and supply an IConnectivity implementation (e.g. AddConnectivity() from Shiny.Core.Linux or Shiny.Core.Blazor).
EnhancementAndroid
Background transfers now run on the same unified managed HttpClient + IConnectivity loop as the other non-iOS platforms (hosted inside the existing foreground service), bringing resumable downloads via HTTP Range requests to Android.
Feature
New Shiny.Net.Http.Blazor package — background HTTP transfers for Blazor WebAssembly using the Service Worker Background Sync API. Queued transfers are persisted to IndexedDB and drained by a Service Worker via fetch() even while the browser tab is closed (where supported). Download bodies are stored back in IndexedDB; the C# manager reconciles results and fires IHttpTransferDelegate callbacks when the tab reopens. Registered via services.AddBlazorHttpTransfers<TDelegate>(). Ship the bundled ./_content/Shiny.Net.Http.Blazor/http-transfer-sw.js SW or importScripts it from your own service worker. v1 limitations: downloads are not resumable (SW fetches return a whole Blob); upload bodies are base64-bridged through JS interop; Background Sync is Chromium-only (Firefox/Safari fall back to foreground drain).
Enhancement
RemoteFileName property on AzureBlobStorageUploadRequest allows overriding the uploaded file name
Enhancement
Azure Blob Storage URI now includes the file path
Enhancement
AWS S3 support added

The library was redesigned from the ground up to match the platform-tier guarantees of Shiny.Net.Http. The new package is Shiny.Data.Sync (note the dot — Shiny.DataSync was the previous, retired SQLite-based prototype). The API and packaging are not compatible with the older version.

Feature
Outbox / inbox engine with platform-aware transports — iOS / Mac Catalyst on background NSURLSession, Android on a foreground service, Windows / Linux / macOS / base .NET on HttpClient + connectivity loop, Blazor WASM on HttpClient + LocalStorage
Feature
IDataSyncManager with Queue<T>, PullNow<T>, PullAll, GetPending, Cancel, CancelAll<T>, and CancelAll — operations are persisted per-app-launch and resume on relaunch
Feature
ISyncEntity contract — a single Identifier property; no base classes, no attributes required
Feature
Per-endpoint SyncDirection: Both (default), PullOnly, PushOnly
Feature
Batch = true per endpoint — coalesces queued ops with last-write-wins semantics (Create + Update(s) → single Create with latest payload; trailing Delete wins) and POSTs a single /batch request
Feature
Pull scheduling via MinPullIntervalTimeSpan.Zero (default) always pulls on scheduled passes, a positive value throttles, and null makes the endpoint manual only (scheduled pulls skip it; PullNow<T> always bypasses). Configurable cursor parameter (CursorParameter, default since)
Feature
Overlapping pulls are coalesced per entity type — if a pull for a type is already in flight, a concurrent trigger (SyncJob, connectivity restore, or PullNow<T>) is skipped rather than racing on the same cursor
Feature
Per-verb URL overrides (PullUrl, BatchUrl) and metered-connection gating (UseMeteredConnection)
Feature
Exponential backoff with persisted NextAttemptAtMaxAttempts (default 5) and RetryBaseDelay (default 2s) per endpoint, capped at 60s, transient on 0 / 408 / 429 / 5xx
Feature
Conflict resolution with ConflictPolicy (AskDelegate / ServerWins / ClientWins) and delegate-driven ConflictResolution.AcceptRemote / KeepLocal / UseMerged
Feature
Three removal strategies: Verb = Delete items in the inbox, separate TombstoneUrl stream (string[] or { cursor, ids }), and client-side SoftDeletePredicate / ExpiryPredicate
Feature
ISyncInterceptor for global request hooks (auth, signing, tracing) — runs before per-endpoint OnBeforeSend so endpoint logic still wins on header conflicts
Feature
Unified Activity event stream surfacing OutboxQueued / OutboxStarted / OutboxSent / OutboxFailed / OutboxConflict / OutboxRetryScheduled / OutboxCanceled / InboxPullStarted / InboxItemReceived / InboxPullCompleted / InboxPullFailed / TombstonesApplied
Feature
Typed events kept for fine-grained subscribers: PendingCountChanged, UpdateReceived (per-op state transitions), PullCompleted (per-endpoint completion)
Feature
AOT-safe JSON via [ShinyJsonContext] source-generated module initializer wiring entities into the shared Shiny.Json.Default chain — no per-endpoint JsonTypeInfo plumbing
Feature
SyncJob auto-registered with WithInternet(InternetAccess.Any) — periodic PullAll runs in the background without any manual AddJob registration
Feature
Named HttpClient RestSyncTransport.HttpClientName ("Shiny.Data.Sync") — attach base address, Polly handlers, and signing handlers with IHttpClientFactory
Feature
Custom transports via ISyncTransport — replace the default REST transport with gRPC, GraphQL, or anything else
Feature
Shiny.Data.Sync.Blazor for Blazor WebAssembly — LocalStorage-backed persistence and connectivity-driven HttpClient loop
FixAndroid
AndroidX version pins
FixAndroid
AndroidX version pins
FixAndroid
AndroidX version pins
Feature
Windows support added (No Background Support at this time)
Enhancement
ManagedScanResult is now passed with the full advertisement data in case user needs access to native internals
EnhancementAndroid
Improved manufacturer data parsing in ad data
FixAndroid
BLE Delegate now reports proper status changes for enabled
Fix
ManagedScan now uses thread safe BindingList
FixAndroid
BLE scan now disables legacy scanning for new android versions
Fix
More thread safetying for ManagedScan
FixAndroid
Ensure peripheral cleanup matches iOS
FixAndroid
BLE Delegate was not responding with Available when adapter was reenabled
Feature
Windows support added (No Background Support at this time)
Feature
Windows support added (No Background Support at this time)
FixAndroid
Don’t request ACCESS_BACKGROUND_LOCATION unless realtime GPS request and less than API level 31 OR standard location background tracking is being requested
Fix
The base GpsDelegate calculations could receive a batch and trigger multiple calculations. This has been made into a synchronized operation
FixiOS
Geofence Manager RequestState works on new CLMonitor API
EnhancementiOS
GPS background will now request background permission right away
Enhancement
GpsDelegate now has a boolean to detect if stationary
EnhancementiOS
iOS 18+ now uses CLMonitor for GPS
Enhancement
New geofence registration mechanics for iOS 17+
FixAndroid
Successful jobs that run too long often tend to have Android completion already disposed
EnhancementiOS
You can now IFDEF IOS to get an AppleNotification that contains the raw NSDictionary
Enhancement
Azure Notification Hubs now allow template registrations
Feature
Windows support added (No Background Support at this time)
Fix
HttpTransferMonitor now uses thread safe BindingList
FixAndroid
File uploads now check to make sure file exists before queuing and downloads directories are checked to ensure they exist before queuing
FixiOS
Send filenames with special characters properly and improved form data upload
Enhancement
Transfers can now be UploadMultipart, UploadRaw (body is raw bytes), or Download - this necessary for sending directly to Azure Blob Storage
Enhancement
AzureBlobStorageRequest.CreateForAzureBlobStorage static helper method
Enhancement
New HttpTransferDelegate allows you to set retries and detect denied authorization allowing you to refresh your token and issue a new request
Enhancement
HTTP Remote configuration has been moved directly into Shiny.Extensions.Configuration
FixAndroid
Disable legacy scanner on newer Android versions
FixAndroid
ManagedScanResult now has a property for the raw advertisement data
FixAndroid
Additional thread safety on managed scan events
  • FixAndroid
    OnEntry intent action was not being defaulted if not set
  • EnhancementAndroid
    OnReceived now sends an AndroidPushNotification which gives you access to the native message as well as helper methods to send the notification if in the foreground
public class MyPushDelegate : IPushDelegate
{
public void OnReceived(PushNotification notification)
{
#if ANDROID
var android = (AndroidPushNotification)notification;
// android.NativeMessage;
var builder = android.CreateNotificationBuilder();
android.SendNotification(1, builder);
#endif
}
}
  • EnhancementAndroid
    Ability to set a custom push intent instead of the Shiny static string
  • Enhancement
    Ensure OnRegistered is always called
  • FixAndroid
    IPushDelegate.OnRegistered was being called twice during IPushManager.RequestAccess calls
  • FixAndroid
    IPushDelegate.OnRegistered was not passing provider token
FixAndroid
IBleDelegate now reports adapter state properly
FixAndroid
BleDelegate does not fire for disconnected event on Android
FixAndroid
Unsubscribing from a connection may be temporarily unstable if sub/unsub is performed rapidly
FixAndroid
Reduce logging severity for characteristic events
FixiOS
IsScanning flag was not being set
Fix
Characteristic extension (GetAllCharacteristics) was only returning characteristics from last service
Fix
Characteristic async extension signature fixes
FixAndroid
Use notification audio channel
  • BREAKINGEnhancement
    PushDelegate now contains an OnUnRegistered event
  • BREAKINGEnhancement
    PushDelegate.OnTokenRefreshed has been renamed to OnNewToken to be more concise as to its purpose
  • EnhancementAndroid
    Firebase will not attempt to initialize if it done by other libraries like Firebase Crashlytics
  • Enhancement
    Microsoft.Azure.NotificationHubs updated to 4.2.0 for FCMv1 parameter which is set as default now
  • Enhancement
    IPushManager now access NativeRegistrationToken which is useful for debugging purposes
EnhancementiOS
Allow fine tuned control of the nsurlsessionconfiguration and the mutable native request on iOS via implementing (& registering) INativeConfigurator
  • EnhancementiOS
    IPushManager.RequestAccess now return AccessState.Unsupported on the simulator instead of letting an exception be thrown
  • Enhancement
    Old extensions for tags added back to IPushManager
  • EnhancementAndroid
    Ability to configure intent action during registration
FixAndroid
Ensure HTTP transfer foreground service does start multiple times
FixAndroid
Check for the presence of FOREGROUND_SERVICE_DATA_SYNC on API 34
EnhancementAndroid
Replace deprecated location request used in Android GPS request
Fixios
Crash tapping local notification on iOS earlier than 16 when accessing FilterCriteria
FixAndroid
Ensure post_notifications permissions is also requested
Enhancement
You can now pass environment variable to configuration that will allow to use file name likes appsettings.apple.debug.json
Enhancement
BLE manager now allows you to check current permissions without requesting
Enhancement
BLE Hosting manager now allows you to check current permissions without requesting
FixAndroid
Managed BLE Services won’t always auto-restart post reboot
Enhancement
GPS & Geofencing managers now allow you to check current permissions without requesting
FixAndroid
GPS won’t always auto-restart post reboot
Fix
Base Job was not calculating runtime difference properly
EnhancementAndroid
Using AndroidNotification, you can now set the android specific Category
FixAndroid
GPS Foreground Service start/stop fix
FixAndroid
Listen to activity.OnCreate intents
EnhancementiOS
IPushDelegate can now add IApplePushDelegate on Apple platforms to manage certain specific return values (UIBackgroundFetchResult & UIPresentationOptions) - Example below
FixAndroid
OnEntry now responds to OnCreate for new activities
#if IOS
using UIKit;
using UserNotifications;
#endif
public partial class MyPushDelegate : Shiny.Push.IPushDelegate
{
// .. left empty for brevity
}
#if IOS
public partial class MyPushDelegate : Shiny.Push.IApplePushDelegate
{
// this is executed only in the foreground
public UNNotificationPresentationOptions? GetPresentationOptions(PushNotification notification)
{
return UNNotificationPresentationOptions.Alert;
}
// executed for all content-available notifications
public UIBackgroundFetchResult? GetFetchResult(PushNotification notification)
{
return UIBackgroundFetchResult.NewData;
}
}
#endif
FixAndroid
More aggressive retrying of transfers in queue
FixAndroid
Appropriate amount of wizardary applied to remove foreground service notifications
FixiOS
Provider push token is now returned by PushManager.RequestAccess instead of native token
Fix
Race condition when subscribed to ShinySubject based subjects
EnhancementAndroid
RequestAccess(bool connect) now allows you to additionally request access to GATT connections (defaults to true). This allows Shiny to use Android API 31 properly. It will always ask for scan permissions.
BREAKINGAndroid
Adapter control is no longer support through the Shiny API, but you do have raw access to the native adapter if needed
BREAKING
Managed scan now require you to set scan configuration values in Start instead of the constructor & property setters
BREAKING
The API has been simplified and no longer requires you to maintain (and refresh) instances of services/characteristics/descriptors
BREAKING
Managed peripheral is now gone. This functionality is now built into the main API.
BREAKINGEnhancementAndroid
Android MTU requests are moved to the IPeripheral.Connect(AndroidConnectionConfig)
EnhancementAndroid
RequestAccess now exists - you can specifically target your permissions to take advantage of Android API 31
Enhancement
All characteristic hooks are now async
Enhancement
New “managed” model for characteristics
Enhancement
Advertise iBeacons is now supported - it exists here instead of Shiny.Beacons because all of the advertising code is here
EnhancementApple
You can now control location manager properties like ActivityType and ShowsBackgroundLocationIndicator via AppleLocationConfiguration service
EnhancementAndroid
To configure the foreground service notification, your IGpsDelegate can also implement IAndroidForegroundServiceDelegate with ANDROID preprocessor directives
BREAKINGiOS
We no longer support the background fetch style (old) job management - only bgtasks will be used going forward
EnhancementAndroid
Android 13 Support for new POST_NOTIFICATION permissions
Enhancement
OS specific configuration for Android and iOS
Enhancement
Ability to customize actual native notification before it is sent/queued
Enhancement
Improved sound customization via new channel flag - Channel.Sound = ChannelSound.Custom|High|Default|None
EnhancementAndroid
Android 13 Support for new POST_NOTIFICATION permissions
Enhancement
Now works on new xplat lifecycle management from Core
Enhancement
Internally rewritten to make architecture easier going forward - firebase, azure, etc all become plugins on top of native instead of full implementations
Enhancement
Rewritten API makes it easier than ever to monitor metrics of your transfers
EnhancementAndroid
Now supports persistent progress notifications
Enhancement
You can pass AppleHttpTransferRequest & AndroidHttpTransferRequest to the HttpTransferManager to customize the native request
  • Configuration is now part of the core library
    Enhancement
    Now loads platform specific json assets like appsettings.android.json, appsettings.ios.json, appsettings.maccatalyst.json, & appsettings.apple.json
Feature
IContactStore interface — full CRUD operations for device contacts: GetAll, GetById, Create, Update, Delete
Feature
LINQ query supportIQueryable<Contact> with native translation for Contains, StartsWith, EndsWith, Equals on name, phone, and email fields
Feature
MAUI permissionsContactPermission class wrapping both READ_CONTACTS and WRITE_CONTACTS with Granted, Limited, and Denied states on Android
Feature
Permission extensionsRequestPermissionsAsync() and CheckPermissionStatusAsync() extension methods on IContactStore
FeatureAndroid
PermissionStatus.Limited — returned when only one of read/write is granted, enabling read-only or write-only scenarios
Feature
GetFamilyNameFirstLetters — extension method returning sorted, distinct first letters of family names for alphabetical index UIs
Feature
Comprehensive contact model — phones, emails, addresses, dates, relationships, websites, organization, photos, and thumbnails
FeatureiOS
Notes entitlement auto-detection — gracefully handles missing com.apple.developer.contacts.notes entitlement at runtime
Feature
DI registrationbuilder.Services.AddContactStore() registers IContactStore as singleton
Feature
AOT and trimmer compatibleIsAotCompatible and EnableTrimAnalyzer enabled