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

Transfer Progress

Background transfers are the most obvious thing to put in front of a user who has left your app: an iOS Live Activity on the Lock Screen and in the Dynamic Island, or the Android foreground-service notification upgraded to an Android 16 live update with a status bar chip.

AddTransferProgress() does both from one manager, so the two platforms cannot drift in what they say. Nothing goes in your transfer delegate.

builder.Services.AddHttpTransfers<MyTransferDelegate>();
builder.Services.AddTransferProgress(opts =>
{
opts.Scope = TransferProgressScope.Summary; // one surface for all, or PerTransfer
opts.Fields = TransferProgressFields.Default; // file, direction, %, bytes, speed, ETA
opts.ShortStatus = TransferProgressShortStatus.Percent;
});

TransferProgressManager subscribes to IHttpTransferManager.UpdateReceived at app startup, coalesces the progress firehose down to one update a second, aggregates a batch into a single figure, and starts, updates and retires the surface — including when iOS relaunches your app in the background to finish a transfer.

Platform Surface
Android 16+ The foreground-service notification, promoted ongoing: status bar chip, always-on display
Android 8–15 The foreground-service notification with a determinate progress bar
iOS/iPadOS 16.2+ A Live Activity — Lock Screen and Dynamic Island
macOS, Mac Catalyst, tvOS, Windows, Linux, Blazor No renderer available; the manager does nothing

Both renderers ship inside Shiny.Net.Http. There is no second package and no second registration call: on iOS the package pulls Shiny.Mobile.LiveActivities for you — that reference sits on the -ios target alone, so no other head carries ActivityKit — and AddTransferProgress() registers ILiveActivityManager itself if you have not already called AddLiveActivities().

Fields is a [Flags] enum gating the human-readable title and body. Unselected fields are simply not written, and each renderer draws only what it is given — so turning one off removes it from the Lock Screen without touching any Swift.

Flag Example
FileName receipt.pdf
Direction Uploading / Downloading
Percent 41%
TransferredBytes 12 MB of 48 MB
Speed 1.5 MB/s
TimeRemaining 4m 12s left
Host uploads.example.com

ShortStatus picks the single value for the tightest surfaces — the Dynamic Island compact view and the Android status bar chip. Percent is left out of the body when it is already the short status, so it never prints twice.

Raw, culture-invariant values (bytes, total, percent, bps, etaSeconds, state, direction, transferId, fileName, uri) always ride in TransferProgressContent.Data for a custom iOS widget to format itself, unless you set IncludeRawData = false.

For localization, or wording the built-ins do not cover, implement ITransferProgressDelegate — or subclass TransferProgressDelegate and override only what you need. Returning null keeps the built-in string.

public class MyProgressText(IStringLocalizer localizer) : TransferProgressDelegate
{
public override string? GetTitle(TransferProgressSnapshot snapshot)
=> snapshot.IsUpload ? localizer["Sending"] : null; // null => keep the built-in
}
builder.Services.AddTransferProgress<MyProgressText>();

A background NSURLSession delivers no progress callbacks while your app is suspended: DidWriteData/DidSendBodyData stop firing and iOS only wakes the app when the transfer completes. A fraction-based bar therefore freezes for most of a long transfer.

That is why ProjectTimeRemaining is on by default. Progress is emitted as a time range rather than a fraction, which the system animates on its own — anchored in the past, at the point a constant-rate transfer would have begun, so the bar already sits at the true fraction. (Anchoring at “now” would snap the bar back to zero on every update.) Every real callback re-anchors it.

It falls back to a plain fraction when the transfer is stalled, paused, of unknown size, or when the estimate exceeds MaximumProjection (one hour by default). Android resolves the range straight back to a fraction — its foreground service is alive throughout, so real progress keeps arriving and the bar never has to coast.

For uploads you can go further: set opts.LiveActivity.RequestPushToken = true and your server, which knows how many bytes actually landed, can push byte-accurate progress through the whole suspended window. It buys nothing for downloads, where no server knows how far the device has got. The token arrives on ILiveActivityDelegate.OnPushTokenChanged.

Option Default What it does
Scope Summary One surface for all transfers, or one per transfer
Fields Default Which fields the title/body may mention
ShortStatus Percent The single value for the Dynamic Island / status chip
MinimumUpdateInterval 1s Floor between two rendered updates
MinimumPercentChange 1% How far progress must move to be worth redrawing
ProjectTimeRemaining true Emit a self-animating time range instead of a fraction
MaximumProjection 1h Beyond this the estimate is nonsense; fall back to a fraction
IncludeRawData true Also emit machine-readable values for custom renderers
StaleAfter 30s When content should be treated as out of date
DismissCompletedAfter 4s How long the final state lingers
AlertOnCompletion false Alert rather than refresh silently when a batch finishes
LiveActivity.Kind shiny.httptransfers Stamped on the activity so a multi-layout widget can branch (iOS)
LiveActivity.RequestPushToken false Ask ActivityKit for a per-activity push token (iOS)
RankByProgress true Rank iOS activities by completion fraction

Register an ITransferProgressRenderer and the same manager drives it — you get the aggregation, coalescing and lifetime for free and only implement the drawing.

public class MyRenderer : ITransferProgressRenderer
{
public bool IsAvailable => true;
public Task Show(string key, TransferProgressContent content) { /* draw */ return Task.CompletedTask; }
public Task Hide(string key, TransferProgressContent content, DateTimeOffset dismissAt) { /* remove */ return Task.CompletedTask; }
public Task Reconcile(IReadOnlyCollection<string> activeKeys) { /* clean up leftovers */ return Task.CompletedTask; }
}
builder.Services.AddSingleton<ITransferProgressRenderer, MyRenderer>();

TransferProgressContentBuilder is public and static, so FormatBytes, FormatRate, FormatDuration and FormatPercent are reusable anywhere — including in ordinary in-app progress UI.