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

Live Activities

GitHub GitHub stars for shinyorg/shiny
Downloads NuGet downloads for Shiny.Mobile.LiveActivities
Operating Systems
iOS
Android
Shiny.Mobile.LiveActivitiesNuGet package Shiny.Mobile.LiveActivities
Shiny.Hosting.MauiNuGet package Shiny.Hosting.Maui

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 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 NoOpLiveActivityManagerIsSupported 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.

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.

builder.Services.AddLiveActivities();
// or, if a server pushes updates — 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 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
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.

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.

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 ActivityAttributes structs; here you get one, with Kind as a discriminator your widget branches on (switch context.attributes.kind). A push-to-start payload must name ShinyActivityAttributes as its attributes-type.
  • The 4KB content-state cap is easier to hit when everything is a string.
  • Interactive activities. A Button or Toggle backed by an AppIntent lives in the extension, in Swift, and ILiveActivityDelegate has no action callback — only OnStarted, OnStateChanged and the two token events. Bridging one back to .NET is on you (an app group, a URL scheme).
  • Alert sound. LiveActivityAlert carries a title and body; Apple’s AlertConfiguration also takes a sound.

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.

// 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 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.

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.