Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

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 16.2+ A Live Activity — Lock Screen and Dynamic Island (add Shiny.Mobile.LiveActivities.HttpTransfers)
Everything else No renderer available; the manager does nothing

iOS ships no renderer in Shiny.Net.Http itself, because ActivityKit needs a Swift widget extension. Add the Shiny.Mobile.LiveActivities.HttpTransfers package and call AddHttpTransferLiveActivities() alongside; it registers an ITransferProgressRenderer and the same manager drives it.

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 RequestPushToken = true on the Live Activities package’s options 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.

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