Live Activities
Getting Started
Section titled “Getting Started”| GitHub | |
| Downloads |
A Live Activity is the persistent, updating status surface both phone platforms grew independently — a delivery on its way, a match score, a workout, a long upload. Shiny puts one typed API in front of both: 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).
The shared contract is a state, not a UI tree. The two platforms render very differently — iOS runs
arbitrary SwiftUI from a widget extension, Android draws a notification — so what crosses the boundary is a
typed LiveActivityContent and nothing else.
Platform support
Section titled “Platform support”| Platform | What you get |
|---|---|
| iOS / iPadOS 16.2+ | A real Live Activity through ActivityKit, rendered by your SwiftUI widget extension |
| Android 16 (API 36)+ | Notification.ProgressStyle with requestPromotedOngoing and setShortCriticalText — status bar chip and AOD |
| 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 and every call is a safe no-op |
Because unsupported platforms no-op rather than throw, shared view models need no #if. Branch on
IsSupported only when the UI should hide the feature entirely.
Packages
Section titled “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. Never reference it directly. |
ActivityKit is Swift-only — it has no Objective-C interface, so there is nothing for the .NET iOS SDK to
bind directly. The binding package wraps it in an @objc bridge built from an Xcode project, which is also
why the repo’s CI runs on macOS.
The namespace is Shiny.LiveActivities; the Mobile segment is a package-scope qualifier, not part of the
API surface.
Registration
Section titled “Registration”builder.Services.AddLiveActivities();
// or, if a server pushes updates — the delegate is the only way to learn the tokensbuilder.Services.AddLiveActivities<MyLiveActivityDelegate>();Both overloads take an optional Action<LiveActivityOptions>. It is Android-only — iOS has no
notification channel and nothing app-settable to configure:
builder.Services.AddLiveActivities(o =>{ o.ChannelName = Strings.LiveActivityChannelName; o.ChannelDescription = Strings.LiveActivityChannelBlurb;});| Option | Default | What it does |
|---|---|---|
ChannelName |
"Live Activities" |
The channel name shown in Android’s per-app notification settings |
ChannelDescription |
"Ongoing updates such as deliveries, timers and scores" |
The description under it; null leaves it blank |
Start, update, end
Section titled “Start, update, end”public class OrderTracker(ILiveActivityManager activities){ string? activityId;
public async Task Begin(string orderNumber) { if (!activities.IsSupported) return;
if (await activities.RequestAccess() != 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", Content = new LiveActivityContent { Title = "Order confirmed", Body = "Preparing your order", ShortStatus = "0%", Progress = LiveActivityProgress.FromValue(0.0) } }); this.activityId = activity.Id; }
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() lists what is running, newest first. EndAll() clears everything — call it on logout. Pass a
LiveActivityAlert to Update to surface a banner and a tap on a paired watch instead of refreshing
silently.
Content
Section titled “Content”| Property | Purpose |
|---|---|
Title |
The headline — order status, team names, “Arriving in 5 min” |
Body |
Supporting detail under the title |
ShortStatus |
A handful of characters for the tightest surfaces: the Dynamic Island compact view and the Android status bar chip |
Progress |
Optional progress indicator — see below |
StaleDate |
When the content should be treated as out of date; the system flips the activity to LiveActivityState.Stale so your widget can render an “out of date” view. It does not end the activity |
RelevanceScore |
Ranks this activity against your app’s others for the Dynamic Island (iOS) |
Data |
Free-form string/string values your own widget reads |
What you can drive from C#, and what you can’t
Section titled “What you can drive from C#, and what you can’t”Worth understanding before you design against this, because it shapes what fits.
The layout is never C#
Section titled “The layout is never C#”WidgetKit requires the Lock Screen and Dynamic Island views to be SwiftUI, in a widget extension signed with your own bundle id. No NuGet package can change that. C# only ever sends state; the drawing is Swift you own. Visually you are unconstrained — it just is not C#. The bundled template means most apps never open Xcode, but a custom look is Swift.
Android has no equivalent constraint; the library posts the notification itself.
There is exactly one ActivityAttributes type
Section titled “There is exactly one ActivityAttributes type”ActivityKit needs a concrete Codable type at compile time, which would normally force every app to
hand-write its own Swift — and makes a general-purpose .NET API impossible. Shiny pins a single
ShinyActivityAttributes instead, with the fields that matter to the system strongly typed and everything
app-specific in string dictionaries:
| Strongly typed | Free-form |
|---|---|
title, body, shortStatus, progress, progressStart, progressEnd, indeterminate |
data (changes over the activity’s life), values (static, from LiveActivityRequest.Attributes) |
That buys the cross-platform API, and it costs three things:
- Custom fields are strings. A number, date, nested object or array gets flattened on the C# side and parsed in your Swift. Only the progress family is genuinely typed.
- One attributes type for the whole app. Apple lets you declare several distinct
ActivityAttributesstructs; here you get one, withKindas a discriminator your widget branches on (switch context.attributes.kind). A push-to-start payload must nameShinyActivityAttributesas itsattributes-type. - The 4KB content-state cap is easier to hit when everything is a string.
Not reachable through this package
Section titled “Not reachable through this package”- Interactive activities. A
ButtonorTogglebacked by anAppIntentlives in the extension, in Swift, andILiveActivityDelegatehas no action callback — onlyOnStarted,OnStateChangedand the two token events. Bridging one back to .NET is on you (an app group, a URL scheme). - Alert sound.
LiveActivityAlertcarries a title and body; Apple’sAlertConfigurationalso takes a sound.
So what fits?
Section titled “So what fits?”Anything whose changing state is title, body, short status, progress, stale date, relevance score and a string bag — which is most real activities: delivery tracking, rideshare ETA, sports scores, timers, workouts, transfer progress.
If you genuinely need your own typed schema, the escape hatch is forking ShinyActivityAttributes.swift
(in both native/ShinyLiveActivities/ and templates/WidgetExtension/) along with
LiveActivityContentSchema. That puts you off the contract shared with Shiny.Extensions.Push, where
drift fails silently rather than throwing — so weigh it.
Prefer a time range over a fraction
Section titled “Prefer a time range over a fraction”// self-advancing: the system animates it with no further updates from youProgress = 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 until the app wakes. A range keeps advancing with no app involvement, and each real update
re-anchors it. Use FromValue when the fraction is genuinely known and not time-shaped, and
Indeterminate = true when it is unknown.
Permissions
Section titled “Permissions”GetCurrentAccess() and RequestAccess() report the iOS per-app Live Activities switch, or notification
permission on Android. iOS has no prompt for Live Activities, so RequestAccess there simply reports the
current setting; Android 13+ asks for POST_NOTIFICATIONS.
Already done for you: HTTP transfer progress
Section titled “Already done for you: HTTP transfer progress”Do not hand-roll a Live Activity for Shiny.Net.Http uploads and downloads.
AddTransferProgress() already drives one on iOS and the foreground-service
notification on Android, from a single manager, with no code in your transfer delegate.
- The iOS widget extension — required on iOS, and the usual reason nothing appears
- Push tokens and server updates


