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.
5.7.0 - September 17, 2026
Section titled “5.7.0 - September 17, 2026”Core & Hosting
Section titled “Core & Hosting”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.BluetoothLE
Section titled “BluetoothLE”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.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.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.BluetoothLE Hosting
Section titled “BluetoothLE Hosting”[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.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.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.Network Discovery
Section titled “Network Discovery”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.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.5.6.4 - September 15, 2026
Section titled “5.6.4 - September 15, 2026”Local Notifications
Section titled “Local 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.5.6.3 - September 14, 2026
Section titled “5.6.3 - September 14, 2026”BluetoothLE
Section titled “BluetoothLE”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.BluetoothLE Hosting
Section titled “BluetoothLE Hosting”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.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.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.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.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.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.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.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.5.6.0 - September 11, 2026
Section titled “5.6.0 - September 11, 2026”Core & Hosting
Section titled “Core & Hosting”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.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.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.BluetoothLE
Section titled “BluetoothLE”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.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.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.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.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.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.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.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.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.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.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.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.BluetoothLE Hosting
Section titled “BluetoothLE Hosting”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.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.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.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.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.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.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.Beacons
Section titled “Beacons”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.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.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.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.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.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.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.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.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.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.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.AccessState.NotSupported and throws from StartMonitoring. Branch on CurrentStatus, not on OperatingSystem.IsMacOS().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.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.Network Discovery
Section titled “Network Discovery”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.Screen Recording
Section titled “Screen Recording”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
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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.Push Notifications
Section titled “Push Notifications”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.Live Activities
Section titled “Live Activities”AddLiveActivities(configure) and AddLiveActivities<TDelegate>(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.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.@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.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+.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.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.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.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.*.HTTP Transfers
Section titled “HTTP Transfers”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.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.Data Sync
Section titled “Data Sync”Shiny.Data.Sync now ships a net10.0-tvos target, using the same NSUrlSession-backed DataSyncManager as iOS and Mac Catalyst.5.5.0 - August 26, 2026
Section titled “5.5.0 - August 26, 2026”BluetoothLE
Section titled “BluetoothLE”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.BluetoothLE Hosting
Section titled “BluetoothLE Hosting”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.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.IPeripheral.Mtu returned GattSession.MaxPduSize, which is the ATT MTU. It now subtracts the 3-byte ATT header. Same migration note as Android.[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.[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.[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.[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.[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.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."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.Locations
Section titled “Locations”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.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.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.Network Discovery
Section titled “Network Discovery”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
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.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.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.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.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.AddedByThisApp exists to tell the two cases apart and a “manage all my Wi-Fi networks” screen is a desktop-only idea.NEHotspotConfigurationManager — getConfiguredSSIDs 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.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.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.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.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.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.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.IWifiManager.CurrentNetwork is replaced by Task<WifiNetworkInfo?> 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.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.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.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.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.ipv4.method=shared for DHCP and NAT, honours the full HotspotConfiguration, and lists clients from the kernel neighbour table filtered to the hotspot interface.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.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.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.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 <unknown ssid> placeholder and the all-zero BSSID it substitutes without location permission are reported as null rather than passed through as literal strings.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.[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.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.Local Notifications
Section titled “Local Notifications”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.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.HTTP Transfers
Section titled “HTTP Transfers”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.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.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.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.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.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.5.4.0 - August 2026
Section titled “5.4.0 - August 2026”Core & Hosting
Section titled “Core & Hosting”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.BluetoothLE
Section titled “BluetoothLE”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.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.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;.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.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.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.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.BluetoothLE Hosting
Section titled “BluetoothLE Hosting”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.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.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.Network Discovery
Section titled “Network Discovery”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<SsdpBrowseResult> keyed on UDN, and Publish(...) to advertise your own root device. Supported on iOS, Mac Catalyst, macOS, Android, Windows, Linux, and server .NET.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.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.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.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.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.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.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.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.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.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.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.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.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.SO_REUSEADDR/SO_REUSEPORT let it coexist with avahi-daemon, mDNSResponder, or the Windows DNS client — Avahi is not a dependency on Linux.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<T>(key, fallback) for any IParsable<T>.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.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.Contact Store
Section titled “Contact Store”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.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.ContactField.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.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.Calendar Store
Section titled “Calendar Store”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.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.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.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.5.3.0 - July 26, 2026
Section titled “5.3.0 - July 26, 2026”Network Discovery
Section titled “Network Discovery”Initial Release
Calendar Store
Section titled “Calendar Store”ICalendarStore provides full CRUD on calendars and events plus a LINQ Query() over events. Register with builder.Services.AddCalendarStore().CalendarAccessType.READ_CALENDAR / WRITE_CALENDAR permissions and full event CRUD.AppointmentStore backend — reads/queries all calendars; create/update/delete are supported inside an app-owned calendar (system-calendar writes throw).IQueryable<CalendarEvent> pushes calendar id and the Start/End window down to the native fetch, with an exact in-memory pass for everything else.EventAttendee.ResolveContact(IContactStore) bridges an attendee to a device contact by email.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.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.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.SelectionMode, no selection binding, and no filter state in the view model.5.2.5 - July 23, 2026
Section titled “5.2.5 - July 23, 2026”Locations
Section titled “Locations”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.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.Contact Store
Section titled “Contact Store”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.Local Notifications
Section titled “Local Notifications”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.5.2.4 - July 20, 2026
Section titled “5.2.4 - July 20, 2026”BluetoothLE
Section titled “BluetoothLE”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.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.5.2.2 - July 8, 2026
Section titled “5.2.2 - July 8, 2026”HTTP Transfers
Section titled “HTTP Transfers”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.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.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.5.2.0 - July 6, 2026
Section titled “5.2.0 - July 6, 2026”Locations
Section titled “Locations”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.Contact Store
Section titled “Contact Store”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.GetAll() 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.Local Notifications
Section titled “Local Notifications”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.5.1.2 - July 1, 2026
Section titled “5.1.2 - July 1, 2026”Local Notifications
Section titled “Local Notifications”SecurityException when SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM access is unavailable. Request AccessRequestFlags.TimeSensitivity for exact, on-time delivery.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.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.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.5.1.1 - July 1, 2026
Section titled “5.1.1 - July 1, 2026”HTTP Transfers
Section titled “HTTP Transfers”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.Data Sync
Section titled “Data Sync”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.5.1.0 - June 28, 2026
Section titled “5.1.0 - June 28, 2026”BluetoothLE
Section titled “BluetoothLE”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).NotifyCharacteristic now subscribes to characteristic updates before calling SetNotifyValue(true), closing the same notify-before-listen race on Apple platforms (iOS, Mac Catalyst, macOS).Push Notifications
Section titled “Push Notifications”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 diagnosisShiny.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 unchangedFirebaseAnalytics.SetUserProperty was bound to the wrong Objective-C selector and would have thrown unrecognized selector at runtimeRequestAccess() throws Firebase has not been configured rather than awaiting a completion block that will never fireHTTP Transfers
Section titled “HTTP Transfers”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.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.206 Partial Content → FileMode.Append); iOS resumes natively through the background NSUrlSession. Partial files are preserved on pause and only deleted on cancel.5.0.2 - July 26, 2026
Section titled “5.0.2 - July 26, 2026”Push Notifications
Section titled “Push Notifications”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.5.0.1 - June 28, 2026
Section titled “5.0.1 - June 28, 2026”BluetoothLE
Section titled “BluetoothLE”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).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.Local Notifications
Section titled “Local Notifications”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 notificationsNo 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.RequestAccess/GetCurrentAccess - ToastNotifier.Setting is unsupported without package identity, so access is now reported as available instead of failingPush Notifications
Section titled “Push Notifications”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.5.0.0 - June 20, 2026
Section titled “5.0.0 - June 20, 2026”BluetoothLE
Section titled “BluetoothLE”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.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).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.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.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.Shiny.BluetoothLEShiny.BluetoothLE.Linux packageShiny.BluetoothLE.Blazor package. Central role only; scans require a user gesture, HTTPS, and a Chromium-based browserConnect() now closes any prior BluetoothGatt before opening a new client, so reconnect loops no longer leak GATT clients into Android’s per-app limit.BehaviorSubject / time-windowed ReplaySubject), so subscribers that hook up immediately after calling Connect() no longer miss the resulting state change or failure.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.BluetoothGatt instead of a captured (possibly closed) reference, preventing spurious status 133 errors on the next operation after a disconnect/reconnect cycle.Connecting state.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.IBleManager.GetConnectedPeripherals() now seeds from RetrieveConnectedPeripherals so devices connected by other apps or restored sessions are visible.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.BluetoothCacheMode.Uncached, so a reconnect always re-discovers fresh GattDeviceService / GattCharacteristic handles instead of silently operating on dead, OS-cached ones.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.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.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.BluetoothLE Hosting
Section titled “BluetoothLE Hosting”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.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).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.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.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.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).Shiny.BluetoothLE.Hosting.Linux packageLocations
Section titled “Locations”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.Type-based AddGeofencing(Type delegateType) and AddGpsDirectGeofencing(Type delegateType) overloads have been removed. Use the generic AddGeofencing<TDelegate>() / AddGpsDirectGeofencing<TDelegate>() instead.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.IMotionActivityManager with AddMotionActivity() registration.IMotionActivityDelegate for background motion activity processing.CMMotionActivityManager. Requires NSMotionUsageDescription in Info.plist.GpsReading.IsStationary is set automatically on Android, iOS legacy, and iOS 18+ before readings reach delegates or the observable stream.AndroidGpsRequest now exposes StationaryMetersThreshold and StationarySecondsThreshold to configure stationary detection sensitivity.AppleGpsRequest now exposes StationaryMetersThreshold and StationarySecondsThreshold for legacy iOS stationary detection. iOS 18+ continues to use native CLLocationUpdater stationary detection.GpsDelegate. The IsStationary, DetectStationary, StationaryMetersThreshold, and StationarySecondsThreshold properties have been removed. Use GpsReading.IsStationary instead.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.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.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.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.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.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)).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.WAKE_LOCK permission is no longer used or needed by Shiny.Jobs — the only consumer was the now-removed wake-lock task wrapping.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.ShinyJobsBackgroundTask.RegisterComServer() once in your App constructor before any trigger registration, and declare a matching windows.comServer extension in your appx manifest.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.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.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.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.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.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.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.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.Local Notifications
Section titled “Local Notifications”UNUserNotificationCenterToastNotificationManager (channels are limited compared to mobile)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.Push Notifications
Section titled “Push Notifications”Shiny.PushShiny.PushShiny.Push.Blazor package. Requires HTTPS, a VAPID key pair, and a service worker; Safari requires the user to install the PWA before subscribingShiny.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 supportHTTP Transfers
Section titled “HTTP Transfers”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).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.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.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.HttpClientHttpTransferProcess. Android, Windows, Linux, macOS, and plain .NET now share one driver; the platform-specific Android HttpTransferProcess and Windows HttpTransferProcess have been removed.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 Content → FileMode.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).HttpClient + IConnectivity loop as the other non-iOS platforms (hosted inside the existing foreground service), bringing resumable downloads via HTTP Range requests to Android.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).Data Sync
Section titled “Data Sync”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.
NSURLSession, Android on a foreground service, Windows / Linux / macOS / base .NET on HttpClient + connectivity loop, Blazor WASM on HttpClient + LocalStorageIDataSyncManager with Queue<T>, PullNow<T>, PullAll, GetPending, Cancel, CancelAll<T>, and CancelAll — operations are persisted per-app-launch and resume on relaunchISyncEntity contract — a single Identifier property; no base classes, no attributes requiredSyncDirection: Both (default), PullOnly, PushOnlyBatch = 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 requestMinPullInterval — TimeSpan.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)SyncJob, connectivity restore, or PullNow<T>) is skipped rather than racing on the same cursorPullUrl, BatchUrl) and metered-connection gating (UseMeteredConnection)NextAttemptAt — MaxAttempts (default 5) and RetryBaseDelay (default 2s) per endpoint, capped at 60s, transient on 0 / 408 / 429 / 5xxConflictPolicy (AskDelegate / ServerWins / ClientWins) and delegate-driven ConflictResolution.AcceptRemote / KeepLocal / UseMergedVerb = Delete items in the inbox, separate TombstoneUrl stream (string[] or { cursor, ids }), and client-side SoftDeletePredicate / ExpiryPredicateISyncInterceptor for global request hooks (auth, signing, tracing) — runs before per-endpoint OnBeforeSend so endpoint logic still wins on header conflictsActivity event stream surfacing OutboxQueued / OutboxStarted / OutboxSent / OutboxFailed / OutboxConflict / OutboxRetryScheduled / OutboxCanceled / InboxPullStarted / InboxItemReceived / InboxPullCompleted / InboxPullFailed / TombstonesAppliedPendingCountChanged, UpdateReceived (per-op state transitions), PullCompleted (per-endpoint completion)[ShinyJsonContext] source-generated module initializer wiring entities into the shared Shiny.Json.Default chain — no per-endpoint JsonTypeInfo plumbingSyncJob auto-registered with WithInternet(InternetAccess.Any) — periodic PullAll runs in the background without any manual AddJob registrationRestSyncTransport.HttpClientName ("Shiny.Data.Sync") — attach base address, Polly handlers, and signing handlers with IHttpClientFactoryISyncTransport — replace the default REST transport with gRPC, GraphQL, or anything elseShiny.Data.Sync.Blazor for Blazor WebAssembly — LocalStorage-backed persistence and connectivity-driven HttpClient loop4.0.1 - March 26, 2026
Section titled “4.0.1 - March 26, 2026”Locations
Section titled “Locations”Local Notifications
Section titled “Local Notifications”4.0.0 - March 26, 2026
Section titled “4.0.0 - March 26, 2026”BluetoothLE
Section titled “BluetoothLE”BluetoothLE Hosting
Section titled “BluetoothLE Hosting”Locations
Section titled “Locations”Push Notifications
Section titled “Push Notifications”HTTP Transfers
Section titled “HTTP Transfers”Configuration
Section titled “Configuration”3.3.4 - April 22, 2024
Section titled “3.3.4 - April 22, 2024”BluetoothLE
Section titled “BluetoothLE”3.3.3 - April 8, 2024
Section titled “3.3.3 - April 8, 2024”Push Notifications
Section titled “Push Notifications”-
FixAndroidOnEntry intent action was not being defaulted if not set
-
EnhancementAndroidOnReceived 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 }}3.3.2 - March 29, 2024
Section titled “3.3.2 - March 29, 2024”Push Notifications
Section titled “Push Notifications”-
EnhancementAndroidAbility to set a custom push intent instead of the Shiny static string
-
EnhancementEnsure OnRegistered is always called
3.3.1 - March 26, 2024
Section titled “3.3.1 - March 26, 2024”Push Notifications
Section titled “Push Notifications”-
FixAndroidIPushDelegate.OnRegistered was being called twice during IPushManager.RequestAccess calls
-
FixAndroidIPushDelegate.OnRegistered was not passing provider token
3.3.0 - March 18, 2024
Section titled “3.3.0 - March 18, 2024”BluetoothLE
Section titled “BluetoothLE”Local Notifications
Section titled “Local Notifications”Push Notifications
Section titled “Push Notifications”-
BREAKINGEnhancementPushDelegate now contains an OnUnRegistered event
-
BREAKINGEnhancementPushDelegate.OnTokenRefreshed has been renamed to OnNewToken to be more concise as to its purpose
-
EnhancementAndroidFirebase will not attempt to initialize if it done by other libraries like Firebase Crashlytics
-
EnhancementMicrosoft.Azure.NotificationHubs updated to 4.2.0 for FCMv1 parameter which is set as default now
-
EnhancementIPushManager now access NativeRegistrationToken which is useful for debugging purposes
HTTP Transfers
Section titled “HTTP Transfers”3.2.4 - February 3, 2024
Section titled “3.2.4 - February 3, 2024”Push Notifications
Section titled “Push Notifications”-
EnhancementiOSIPushManager.RequestAccess now return AccessState.Unsupported on the simulator instead of letting an exception be thrown
-
EnhancementOld extensions for tags added back to IPushManager
3.2.3 - January 18, 2024
Section titled “3.2.3 - January 18, 2024”Push Notifications
Section titled “Push Notifications”-
EnhancementAndroidAbility to configure intent action during registration
HTTP Transfers
Section titled “HTTP Transfers”3.2.2 - January 3, 2024
Section titled “3.2.2 - January 3, 2024”Locations
Section titled “Locations”Local Notifications
Section titled “Local Notifications”Push Notifications
Section titled “Push Notifications”3.2.1 - December 19, 2023
Section titled “3.2.1 - December 19, 2023”Locations
Section titled “Locations”Configuration
Section titled “Configuration”3.2.0 - December 8, 2023
Section titled “3.2.0 - December 8, 2023”BluetoothLE
Section titled “BluetoothLE”BluetoothLE Hosting
Section titled “BluetoothLE Hosting”Locations
Section titled “Locations”3.1.2 - November 7, 2023
Section titled “3.1.2 - November 7, 2023”Local Notifications
Section titled “Local Notifications”3.1.1 - November 1, 2023
Section titled “3.1.1 - November 1, 2023”Locations
Section titled “Locations”Local Notifications
Section titled “Local Notifications”3.1.0 - October 26, 2023
Section titled “3.1.0 - October 26, 2023”Push Notifications
Section titled “Push Notifications”#if IOSusing UIKit;using UserNotifications;#endif
public partial class MyPushDelegate : Shiny.Push.IPushDelegate{ // .. left empty for brevity}
#if IOSpublic 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; }}#endifHTTP Transfers
Section titled “HTTP Transfers”3.0.1 - September 19, 2023
Section titled “3.0.1 - September 19, 2023”Push Notifications
Section titled “Push Notifications”HTTP Transfers
Section titled “HTTP Transfers”3.0.0 - September 5, 2023
Section titled “3.0.0 - September 5, 2023”BluetoothLE
Section titled “BluetoothLE”BluetoothLE Hosting
Section titled “BluetoothLE Hosting”Locations
Section titled “Locations”Local Notifications
Section titled “Local Notifications”Push Notifications
Section titled “Push Notifications”HTTP Transfers
Section titled “HTTP Transfers”Configuration
Section titled “Configuration”- Configuration is now part of the core library
EnhancementNow loads platform specific json assets like appsettings.android.json, appsettings.ios.json, appsettings.maccatalyst.json, & appsettings.apple.json
1.0.0 - March 24, 2026
Section titled “1.0.0 - March 24, 2026”Contact Store
Section titled “Contact Store”GetAll, GetById, Create, Update, DeleteIQueryable<Contact> with native translation for Contains, StartsWith, EndsWith, Equals on name, phone, and email fieldsContactPermission class wrapping both READ_CONTACTS and WRITE_CONTACTS with Granted, Limited, and Denied states on AndroidRequestPermissionsAsync() and CheckPermissionStatusAsync() extension methods on IContactStorePermissionStatus.Limited — returned when only one of read/write is granted, enabling read-only or write-only scenarioscom.apple.developer.contacts.notes entitlement at runtimebuilder.Services.AddContactStore() registers IContactStore as singletonIsAotCompatible and EnableTrimAnalyzer enabled

