Shiny Live Activities
Shiny.Mobile.LiveActivities puts one API in front of the persistent, updating status surface both phone
platforms grew: iOS/iPadOS Live Activities (ActivityKit — Lock Screen and Dynamic Island) and
Android 16 Live Updates (a promoted ongoing notification that also renders as a status bar chip and on
the always-on display).
Platform support — read this first
| Platform | What you get |
|---|---|
| iOS / iPadOS 16.2+ | A real Live Activity via ActivityKit, rendered by your SwiftUI widget extension |
| Android 16 (API 36)+ | Notification.ProgressStyle + requestPromotedOngoing + setShortCriticalText |
| Android 8-15 | An ordinary ongoing notification with a determinate progress bar (no chip) |
| macOS, Mac Catalyst, tvOS, Windows, Linux, Blazor | NoOpLiveActivityManager — IsSupported is false, every call is a safe no-op |
There is no macOS or Mac Catalyst implementation, and there never will be one. ActivityKit.framework
ships in the macOS SDK, which misleads people, but every public type in it is annotated
@available(macOS, unavailable), @available(macCatalyst, unavailable), @available(tvOS, unavailable)
and @available(watchOS, unavailable). What a Mac shows is an iPhone's activity mirrored over iPhone
Mirroring — nothing a Mac app declares or drives. That is why the native binding package is named
Shiny.iOS.LiveActivities.Binding, and why the .NET package targets only net10.0, -android and -ios.
Because unsupported platforms get a no-op rather than a throw, shared view models need no #if — branch on
IsSupported only when the UI should hide the feature entirely.
Packages
| Package | Role |
|---|---|
Shiny.Mobile.LiveActivities |
The cross-platform API. This is the one you reference. |
Shiny.iOS.LiveActivities.Binding |
The ActivityKit Swift shim, pulled in automatically on iOS. ActivityKit is Swift-only with no Objective-C interface, so it is bound through an @objc bridge built from native/ShinyLiveActivities by Xcode. Never reference this directly. |
Namespace is Shiny.LiveActivities (the Mobile segment is a package-scope qualifier, not part of the API).
Registration
builder.Services.AddLiveActivities();
// or, if you push updates from a server — the delegate is the ONLY way to learn the tokens
builder.Services.AddLiveActivities<MyLiveActivityDelegate>();
Both overloads take an optional Action<LiveActivityOptions>. It is Android-only — iOS has no
channel and nothing app-settable to configure:
builder.Services.AddLiveActivities(o =>
{
o.ChannelName = Strings.LiveActivityChannelName; // shown in Android notification settings
o.ChannelDescription = Strings.LiveActivityChannelBlurb;
});
Always localize these. They default to English ("Live Activities" / "Ongoing updates such as deliveries, timers and scores") and are re-applied on every startup, so a translation that ships after
first launch still lands. Channel importance and sound are not configurable - Android ignores both
once the channel exists, and from that point they belong to the user.
Start, update, end
public class OrderTracker(ILiveActivityManager activities)
{
string? activityId;
public async Task Begin(string orderNumber)
{
if (!activities.IsSupported)
return;
var access = await activities.RequestAccess();
if (access != AccessState.Available)
return;
var activity = await activities.Start(new LiveActivityRequest
{
// static for the activity's whole life — on iOS this becomes ShinyActivityAttributes
Attributes = new Dictionary<string, string> { ["orderNumber"] = orderNumber },
Kind = "delivery", // your widget branches on this
Content = new LiveActivityContent
{
Title = "Order confirmed",
Body = "Preparing your order",
ShortStatus = "0%", // Dynamic Island compact / Android status bar chip
Progress = LiveActivityProgress.FromValue(0.0)
}
});
this.activityId = activity.Id;
}
// Content REPLACES the previous state — always send the complete content, never a delta
public Task Advance(double percent, string body) => activities.Update(
this.activityId!,
new LiveActivityContent
{
Title = "Out for delivery",
Body = body,
ShortStatus = $"{percent:P0}",
Progress = LiveActivityProgress.FromValue(percent)
}
);
public Task Finish() => activities.End(
this.activityId!,
new LiveActivityContent { Title = "Delivered", Progress = LiveActivityProgress.FromValue(1.0) },
dismissAt: DateTimeOffset.UtcNow.AddMinutes(2)
);
}
GetAll() returns what is currently running (newest first) and EndAll() clears everything — call it on
logout. Pass a LiveActivityAlert to Update to surface a banner instead of refreshing silently.
What C# can and cannot express
Get this wrong and you will generate code that cannot work. Two separate limits:
1. The layout is never C#. WidgetKit requires SwiftUI in a widget extension signed with the app's own bundle id. C# only ever sends state. Never generate C# that tries to describe a Lock Screen or Dynamic Island layout, and never suggest a package that would.
2. There is exactly one ActivityAttributes type. ActivityKit needs a concrete Codable type at
compile time, so Shiny pins a single ShinyActivityAttributes and routes everything app-specific through
string dictionaries.
| Strongly typed | Free-form |
|---|---|
title, body, shortStatus, progress, progressStart, progressEnd, indeterminate |
data (dynamic), values (static, from LiveActivityRequest.Attributes) |
Consequences to respect when generating code:
- Custom fields must be strings. Flatten numbers, dates, objects and arrays into
Dataon the C# side and parse them in Swift. Only the progress family is typed. Do not invent typed properties onLiveActivityContent. - One attributes type per app. Use
Kindas the discriminator (switch context.attributes.kindin the widget), not severalActivityAttributesstructs. A push-to-start payload namesShinyActivityAttributesas itsattributes-type. - 4KB content-state cap, easier to hit with everything stringified.
Out of reach through this package — say so rather than generating something that compiles and does nothing:
- Interactive activities. A
Button/Togglebacked by anAppIntentlives in the Swift extension, andILiveActivityDelegatehas no action callback (onlyOnStarted,OnStateChanged, and the two token events). Bridging back to .NET needs an app group or URL scheme the developer wires themselves. - Alert sound.
LiveActivityAlertis title + body only.
What fits: anything whose changing state is title / body / short status / progress / stale date / relevance score plus a string bag — delivery tracking, rideshare ETA, sports scores, timers, workouts, transfer progress.
If a genuinely typed schema is required, the only route is forking ShinyActivityAttributes.swift in
both native/ShinyLiveActivities/ and templates/WidgetExtension/ plus LiveActivityContentSchema —
which leaves the contract shared with Shiny.Extensions.Push, where drift fails silently. Flag that
tradeoff rather than doing it silently.
Prefer a time range over a fraction
// self-advancing: the system animates it with no further updates from you
Progress = LiveActivityProgress.FromRange(startedAt, expectedFinish)
Every push update costs budget, and on iOS a suspended app sends none at all — a fraction-based bar simply
freezes. FromRange keeps moving with no app involvement. Use FromValue only when the fraction is
genuinely known and not time-shaped, and Indeterminate = true when it is unknown.
The iOS widget extension is not optional
iOS renders the activity from a SwiftUI widget extension in your app bundle — nothing about the layout
can be driven from C#. Without it, Start succeeds and the activity renders nothing, silently. Copy
templates/WidgetExtension (ShinyLiveActivityWidget.swift + ShinyActivityAttributes.swift, which must
stay byte-identical to the library's copy) and add NSSupportsLiveActivities to Info.plist. The template's
README has the Xcode and .csproj wiring. Check these two things first when an iOS activity never
appears.
Android needs no such thing — POST_NOTIFICATIONS on API 33+, which RequestAccess() asks for.
Push tokens: three kinds, and they are not interchangeable
| Token | Lifetime | PushTokenKind (server) |
Where you get it |
|---|---|---|---|
| Device token | the install | Device |
Shiny.Push |
| Push-to-start (iOS 17.2+) | the install, valid before any activity exists | LiveActivityStart |
OnPushToStartTokenChanged |
| Per-activity update | born and dead with that one activity | LiveActivityUpdate |
OnPushTokenChanged |
public class MyLiveActivityDelegate(IMyApi api) : LiveActivityDelegate
{
public override Task OnPushToStartTokenChanged(string token)
=> api.RegisterToken(token, "LiveActivityStart");
public override Task OnPushTokenChanged(LiveActivity activity, string token)
=> api.RegisterActivityToken(activity.Id, token);
public override Task OnStateChanged(LiveActivity activity) => Task.CompletedTask;
}
Send an ordinary alert to a Live Activity token and APNs answers DeviceTokenNotForTopic, which token
managers read as "invalid" and prune. Keep the kinds separated. A 410 Unregistered on an update token
is normal — that activity ended. Both tokens are null on Android, where you update the activity yourself
(typically from an FCM data message handled by Shiny.Push). Never poll PushToStartToken; watch the
delegate.
The wire contract — three definitions, one shape
LiveActivityContentSchema is the JSON both platforms and a server push agree on:
{
"title": "Out for delivery", "body": "2 stops away", "shortStatus": "5 min",
"progress": 0.65, "progressStart": 774835200.0, "progressEnd": 774838800.0,
"indeterminate": false, "data": { "orderId": "A-1234" }
}
Dates inside content-state are seconds since 2001-01-01 (Swift's stock Codable Date encoding), not
the Unix epoch — LiveActivityContentSchema.ToAppleReferenceSeconds converts. The aps envelope
(timestamp, stale-date, dismissal-date) stays Unix.
A field-name or type drift between the C# LiveActivityContent, the Swift ShinyActivityAttributes.ContentState
(in both native/ and templates/WidgetExtension/), and the server's content-state does not throw —
ActivityKit silently drops the update and the activity just stops refreshing. Change all of them together.
Keep the payload small: ActivityKit caps content-state at 4KB. Data is deliberately string-keyed and
string-valued so the payload is identical whether it came from your app or a server.
Server side
Shiny.Extensions.Push sends these: LiveActivityPush.Start(...) / .Update(...) / .End(...) with
SendLiveActivityToTokens(...). Live Activity pushes use a different push type, topic and aps body from an
ordinary alert — that library handles it.
Already done for you: HTTP transfer progress
Do not hand-roll a Live Activity for Shiny.Net.Http uploads/downloads. AddTransferProgress() in
Shiny.Net.Http already drives one on iOS and the foreground-service notification on Android, from one
manager with no code in your transfer delegate. See the shiny-http-transfers skill.
Gotchas
Updatereplaces, it does not merge. Send the complete content every time.- iOS refuses to start an activity from the background. Expect
Startto fail on a background wake and retry on the next update. - iOS caps how many activities an app may run, and may refuse outright.
Kindis yours to interpret. Your widget branches on it; the stock Android renderer only logs it.RelevanceScoreranks your own activities for the Dynamic Island — iOS only.StaleDateflips the activity toLiveActivityState.Staleso the widget can render an "out of date" view. It does not end it.EndwithoutdismissAtleaves the final state on screen for up to four hours on iOS. Pass a past time to remove it now.