Give the phone both pickers, and settle who signs the APK

The files screen could browse a remote and delete on it, and that was all: there
is no browsable local filesystem on Android for a second pane to show, so the
gesture the desktop is built around — choose on the left, press the arrow — has
nothing to stand on. What replaces it is the platform's own two pickers. ADD
FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and
goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the
selected row.

Both stage through the application's cache, and that copy is a requirement
rather than a shortcut. android-port.md predicted a picked document would be a
third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks,
because an upload resumes from the byte the last attempt reached, and a
content:// URI has no path behind it, no length worth trusting, no promised seek
and no grant that survives the document being edited underneath it. Copying
first costs one class in the head and nothing at all in the shared layers, where
the alternative was every resume rule rewritten around a stream that cannot
rewind. The copy is deleted when the transfer completes, kept while it is stopped
so RESUME still has something to read, and swept at the next launch — which is
the one moment emptying that directory is provably safe, since nothing has
queued anything yet.

Coming out had a decision going in did not: when to ask where it goes. The save
picker is raised before the transfer, so the download runs into the same staging
directory and hands its bytes to a callback the head supplied, held against the
transfer id so a RETRY still lands where the person pointed. Asking afterwards
would put the picker minutes from the button that caused it and, on a phone,
usually while the application is backgrounded and Android will not show one at
all. The cost is that the picker creates its file when it is dismissed, so a
download that then fails leaves an empty one there; that is said on the screen,
in the README and in the manual checks rather than left to be discovered. A
delivery that fails keeps the staged bytes for the sweep instead of throwing away
the one copy of something just fetched over somebody's network.

The foreground service counts transfers now, which is the half of it that
matters most here: a shell survives backgrounding because somebody is looking at
it, and an upload has to survive precisely when nobody is. Queued counts as
active, so putting five files in and locking the phone moves five files. The
seam was built for this and wired to () => 0 because nothing could fill the
queue.

Alongside it, ADR 0010 answers the second question android-port.md left open,
and it had to be answered before the first release rather than at upload time: a
new Play app must use App Bundles and therefore Play App Signing, and an
installed app can only be updated by a package signed with the same key, so the
first release picks an identity for good. The project holds the key, offline and
never in CI — the workflow's package step now says so where somebody would break
it — and a DodoSSH deployment never serves the client, because a download link on
your own server hands the binary that holds the plaintext to the party the whole
threat model is about.

The README's M1 gap note was stale in both halves and is replaced by what is
actually true: credentials have an editor and a REMEMBER tick, and the device key
registers into the TPM under a CNG policy that makes the consent dialog a
condition of using it. What is left is the floor rather than a gap — no TPM, or
no Windows, means the passphrase on every launch.
This commit is contained in:
2026-08-04 10:07:16 +02:00
parent 7b7fd7b2ef
commit ebb88c8ae4
13 changed files with 1032 additions and 59 deletions
+22 -12
View File
@@ -74,27 +74,37 @@ public sealed partial class DodoSshApp : Avalonia.Application
workspace.Start();
// Before anything can queue a transfer, which is the only moment at which emptying this is
// provably safe. What it clears is the copy a stopped upload leaves behind on purpose — kept so
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
DocumentStaging.Sweep();
var viewModel = ComposeShell(paths, caches, workspace, knownHosts, connections);
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
//
// Still zero transfers, and the reason moved rather than went away. v2 built the files screen, so
// this head can now browse a remote — but it cannot start a transfer, because both directions need
// the system document picker that scoped storage forces and that is not built (see FilesScreen).
// So the count is zero because the queue provably cannot have anything in it, not because nothing
// was wired. This is still the seam it arrives through: when the picker lands, this reads the
// queue and Refresh() gets called as transfers start and finish.
// The transfer count is real now that the document picker gives this head a way to start one, and
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
// moves five files.
//
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
//
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
// its own, which is the half that would otherwise leave a notification up over nothing.
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing. Without this the notification would come up when an
// upload started and stay up after it finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) };
return new PhoneShell { DataContext = viewModel };
}
/// <remarks>
@@ -0,0 +1,220 @@
using Avalonia.Controls;
using Avalonia.Platform.Storage;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Turns documents chosen in the system picker into ordinary local files the transfer queue can upload.
/// </summary>
/// <remarks>
/// <para>
/// <b>The way in, and the only one this head has.</b> Android has no browsable local filesystem for a
/// second pane to show — the decision docs/android-port.md took before any of this was built — so a file
/// leaves this phone by being pointed at in the system picker, which hands back a <c>content://</c> URI
/// belonging to whichever app owns the document.
/// </para>
/// <para>
/// <b>Copied rather than streamed, and that is a requirement rather than a shortcut.</b> A document URI has
/// no path behind it, its stream is not promised to be seekable, and the grant that opens it can be revoked
/// or the document edited while an upload is in flight. <c>FileTransferQueue</c> needs all three of the
/// things that costs: a path, a length it can trust, and a seek so a resumed upload starts from the byte
/// the last attempt reached. So the document is copied into this application's own cache first and the copy
/// is what gets queued — a real file, behaving like every other thing in that queue.
/// </para>
/// <para>
/// <b>One directory per file, named by a UUIDv7.</b> Two documents chosen in one go can have the same
/// display name, and two picks a minute apart certainly can; a shared staging directory would make the
/// second copy overwrite the first, which is a data-loss bug that only shows up when somebody uploads two
/// files called <c>config</c>. The directory is the uniqueness, so the file inside it can keep the name the
/// picker gave it — which is the name the remote end gets, because <c>QueueUploads</c> takes it from the
/// path.
/// </para>
/// </remarks>
internal static class DocumentStaging
{
/// <summary>Everything staged in either direction, under one directory so a sweep is one call.</summary>
private static string Root =>
Path.Combine(PhoneEnvironment.CacheDirectory, "staging");
/// <summary>A path in the staging area for a file of this name, with the directory made.</summary>
/// <remarks>
/// <b>One directory per file, named by a UUIDv7</b> — see the type's own remarks for why the uniqueness
/// is the directory rather than the name. Used by both directions: a document copied in for upload, and
/// a download on its way out to the document the save picker made.
/// </remarks>
internal static string NewStagingPath(string? name)
{
var folder = Path.Combine(Root, Guid.CreateVersion7().ToString("n"));
Directory.CreateDirectory(folder);
return Path.Combine(folder, SafeName(name));
}
/// <summary>
/// Asks for documents and copies each one into the cache, returning the paths of the copies.
/// </summary>
/// <remarks>
/// Multiple by design: the queue moves one file at a time, but choosing them is a trip out to another
/// application and back, and making somebody take that trip once per file is the kind of thing a phone
/// is judged on. An empty list means the picker was dismissed, which is not an error and is reported as
/// nothing having happened rather than as a failure.
/// </remarks>
internal static async Task<IReadOnlyList<string>> PickAsync(
TopLevel top,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(top);
var chosen = await top.StorageProvider
.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Files to upload",
AllowMultiple = true,
})
.ConfigureAwait(true);
if (chosen.Count == 0)
{
return [];
}
var staged = new List<string>(chosen.Count);
foreach (var document in chosen)
{
staged.Add(await CopyInAsync(document, cancellationToken).ConfigureAwait(true));
}
return staged;
}
/// <summary>
/// Asks where a download should end up, and returns the document the picker made.
/// </summary>
/// <remarks>
/// <para>
/// <c>ACTION_CREATE_DOCUMENT</c>, which is the only way a file leaves this application: everything else
/// on this phone is either the app's own private storage or somewhere it has no permission to write.
/// The suggested name is the remote file's, because that is the name the person was looking at when
/// they pressed the button, and they can change it in the picker like any other save.
/// </para>
/// <para>
/// <b>The document exists as soon as this returns</b> — the picker creates it, empty, and a download
/// that then fails leaves that empty file behind. Nothing here can prevent it: the alternative is
/// raising the picker after the transfer, minutes later, over whatever the person moved on to, and on
/// Android often while the application is backgrounded and cannot show one at all.
/// </para>
/// </remarks>
internal static async Task<IStorageFile?> PickDestinationAsync(TopLevel top, string name)
{
ArgumentNullException.ThrowIfNull(top);
return await top.StorageProvider
.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Save file",
SuggestedFileName = name,
ShowOverwritePrompt = true,
})
.ConfigureAwait(true);
}
/// <summary>Copies a finished download out to the document the picker made.</summary>
/// <remarks>
/// The write is truncating rather than appending, which matters on a retry: the picker's document is
/// created when it is dismissed and a second attempt writes over the empty — or partly written — file
/// rather than after it.
/// </remarks>
internal static async Task DeliverAsync(IStorageFile destination, string localPath)
{
ArgumentNullException.ThrowIfNull(destination);
var source = new FileStream(
localPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true);
await using (source.ConfigureAwait(false))
{
var target = await destination.OpenWriteAsync().ConfigureAwait(false);
await using (target.ConfigureAwait(false))
{
if (target.CanSeek)
{
target.SetLength(0);
}
await source.CopyToAsync(target).ConfigureAwait(false);
}
}
}
/// <summary>
/// Deletes everything left in the staging directory.
/// </summary>
/// <remarks>
/// Called once at composition, before anything can have queued a transfer, which is what makes deleting
/// the lot safe: at that moment nothing in there belongs to a transfer that could still want it. What it
/// is for is the residue a stopped upload leaves deliberately — the copy is kept so RESUME has something
/// to read — a download whose delivery failed, and whatever a process death left behind mid-copy.
/// </remarks>
internal static void Sweep()
{
try
{
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// A cache that could not be emptied is not a reason to refuse to start. Android reclaims this
// directory itself when the device runs short of storage.
}
}
private static async Task<string> CopyInAsync(IStorageFile document, CancellationToken cancellationToken)
{
var path = NewStagingPath(document.Name);
// Off the interface thread from here down — this is a byte copy of something that can be a hundred
// megabytes, and nothing in it touches a control. The caller's own await is what comes back to the
// interface thread to queue the result. Hence the two-step disposal: configuring the await on a
// using declaration would leave the variable a ConfiguredAsyncDisposable rather than a stream.
var source = await document.OpenReadAsync().ConfigureAwait(false);
await using (source.ConfigureAwait(false))
{
var target = new FileStream(
path, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true);
await using (target.ConfigureAwait(false))
{
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
}
return path;
}
/// <summary>
/// The display name reduced to something that can be a file name here and a name on the remote.
/// </summary>
/// <remarks>
/// A picker's display name comes from whichever application owns the document and is not obliged to be
/// a valid file name — it can carry a separator, which without this would write outside the directory
/// staging just made, and would then be joined onto the remote path by <c>QueueUploads</c>. Both are
/// worth refusing at the one point where the name enters this application.
/// </remarks>
private static string SafeName(string? name)
{
var trimmed = Path.GetFileName(name ?? string.Empty).Trim();
if (trimmed.Length == 0 || trimmed is "." or "..")
{
return "file";
}
return string.Join('_', trimmed.Split(Path.GetInvalidFileNameChars()));
}
}
@@ -77,6 +77,18 @@ internal static class PhoneEnvironment
}
}
/// <summary>Where this phone keeps copies that only have to survive the thing that made them.</summary>
/// <remarks>
/// <c>cacheDir</c> — per-app like <see cref="Paths"/>, and unlike it, reclaimable: Android deletes from
/// here when the device runs short of storage. That is the right trade for the upload staging in
/// <see cref="DocumentStaging"/>, whose files are worthless the moment their transfer finishes, and it
/// is why the profile is not kept here. The cost is stated rather than hidden: a file reclaimed under
/// storage pressure while its upload is still running fails that upload.
/// </remarks>
public static string CacheDirectory =>
Require().CacheDir?.AbsolutePath
?? throw new InvalidOperationException("Android returned no cacheDir for this application.");
/// <summary>
/// The activity currently on screen, or null while the app is backgrounded.
/// </summary>
@@ -19,17 +19,28 @@
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
◆ **And that is why neither DOWNLOAD nor UPLOAD is on this screen.** Both commands exist and both work;
what they work *against* is the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`,
and `LocalPath` starts at `LocalDirectory.Home` — `SpecialFolder.UserProfile`, which on Android is the
application's own private directory. A download would report success and put the file somewhere the
person who asked for it cannot open it, which is worse than not offering it: a refusal is visible and a
file in `/data/user/0/…` is not. The way in and out is the system document picker, which is the shape
docs/android-port.md decided on and is the next piece of work.
◆ **ADD FILES is the way in, and it is the system document picker rather than an UPLOAD button.** There
is nothing local to select from, so the gesture cannot be "choose on the left, press the arrow": it is
"point at a document wherever it lives, and it goes to the directory showing". What Android hands back
is a `content://` URI, so `DocumentStaging` copies it into this application's cache and queues the copy —
the queue needs a path, a length and a seek, and a document URI promises none of the three. See that
class for why the copy is a requirement rather than a shortcut, and `QueueStagedUploads` for when it is
deleted again.
So what ships is browsing a remote, and the two remote-side operations that need nothing local —
opening a directory and deleting. The queue is drawn because a transfer can still be running when this
screen is opened; it is simply not something this head can start yet.
◆ **SAVE FILE is the way out, and it is the save picker rather than a DOWNLOAD button.** `QueueDownloads`
writes to `Path.Combine(LocalPath, name)`, and `LocalPath` on Android is the application's own private
directory — a download that way would report success and leave the file where the person who asked for
it cannot open it. So this head does not use it: `QueueDeliveredDownload` runs the transfer into the
cache and hands the finished bytes to the document `ACTION_CREATE_DOCUMENT` made.
The destination is chosen *before* the transfer, which is a decision with a visible cost — the picker
creates the document when it is dismissed, so a download that then fails leaves an empty file where it
was pointed. The alternative is a picker raised minutes later, over whatever the person moved on to and
frequently while this application is backgrounded, where Android will not show one at all.
So what ships is browsing a remote and moving files both ways, plus the two remote-side operations that
need nothing local — opening a directory and deleting. The queue is drawn under the actions, because
this head can now fill it.
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
and it makes its own trust decision — the host records a second login. So this screen carries its own
@@ -255,6 +266,70 @@
</StackPanel>
</Border>
<!--
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
phone is how a screen becomes unusable rather than merely tall.
-->
<ScrollViewer MaxHeight="164" IsVisible="{Binding HasTransfers}"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Transfers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<Grid ColumnDefinitions="14,*,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12" Text="{Binding Arrow}"
VerticalAlignment="Center" Foreground="{StaticResource AccentText}" />
<StackPanel Grid.Column="1" Margin="8,0" Spacing="3">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<ProgressBar Height="3" Minimum="0" Maximum="100" Value="{Binding Percent}"
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Classes="detail" Text="{Binding Progress}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
One button per row, never two: whichever of the three applies to the state it is in.
A phone row has space for a name, a bar and one 44-pixel target, and the three are
mutually exclusive by construction — IsRunning and CanRetry cannot both hold.
-->
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="STOP"
IsVisible="{Binding IsRunning}"
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
CommandParameter="{Binding}" />
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="{Binding RetryLabel}"
IsVisible="{Binding CanRetry}"
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Classes="secondary" Height="40" Content="CLEAR FINISHED" HorizontalAlignment="Stretch"
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
<!--
◆ The two directions, on their own row above the pair below — because DELETE is the button on this
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this
screen exists for on a phone.
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the
save picker names one destination. CanDownload is the desktop's own flag for the same question —
a file is selected and something is connected — and is reused rather than restated here.
-->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
IsEnabled="{Binding CanDownload}" />
</Grid>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
@@ -263,18 +338,11 @@
</Grid>
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
Text="Copying files to and from this phone needs the system document picker, which is not built yet — see the note at the top of this screen. Browsing, opening and deleting work." />
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
Text="{Binding Status}" />
<!--
There is no queue on this screen, and that follows from the note at the top rather than being a
separate decision: nothing here can enqueue a transfer, so a queue would be a region that is
empty for every possible state of the application. It comes back with the document picker, along
with the two buttons that would fill it.
-->
</StackPanel>
</Border>
@@ -3,6 +3,7 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views;
@@ -29,8 +30,9 @@ internal sealed partial class FilesScreen : UserControl
/// fires after the list has moved its selection, which is what lets this read it.
/// </para>
/// <para>
/// A file is left selected rather than opened. There is nothing this head could do with it — see the
/// note about the document picker at the top of the screen — and the actions below act on the selection.
/// A file is left selected rather than downloaded, and that is deliberate now rather than forced: the
/// actions below act on the selection, and a tap that started a transfer would make selecting a row to
/// read its size the same gesture as fetching it.
/// </para>
/// </remarks>
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
@@ -40,4 +42,93 @@ internal sealed partial class FilesScreen : UserControl
transfers.OpenRemoteCommand.Execute(null);
}
}
/// <summary>
/// Picks documents in the system picker and queues them for upload.
/// </summary>
/// <remarks>
/// <para>
/// In the head rather than in the shared view model, for the reason every other platform difference is:
/// the picker is Android's, the staging directory is this application's cache, and the desktop reaches
/// its local files by browsing a pane that does not exist here. What crosses back into shared code is
/// what the queue understands — paths — through <see cref="TransfersViewModel.QueueStagedUploads"/>.
/// </para>
/// <para>
/// <b>Failures land in the screen's own status line</b>, which is where every other refusal on this
/// screen already is. The picker itself is a trip out to another application, and it can come back with
/// a document that has since been deleted or a grant that was revoked; the exception's message is more
/// use than "the upload failed", and a phone has nowhere else to put it.
/// </para>
/// </remarks>
private async void OnAddFiles(object? sender, RoutedEventArgs e)
{
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
{
return;
}
try
{
var staged = await DocumentStaging.PickAsync(top, CancellationToken.None).ConfigureAwait(true);
if (staged.Count == 0)
{
return;
}
transfers.QueueStagedUploads(staged);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
transfers.Status = $"Those files could not be read: {exception.Message}";
}
}
/// <summary>
/// Asks where the chosen remote file should be saved, then queues it.
/// </summary>
/// <remarks>
/// <para>
/// The other direction, and the asymmetry with <see cref="OnAddFiles"/> is the platform's rather than
/// this screen's: coming in, several documents can be pointed at in one trip; going out, the save picker
/// names one destination, so this acts on the selected row. Asking for five destinations in a row to
/// download five files would be a worse screen than pressing the button five times.
/// </para>
/// <para>
/// Nothing is queued when the picker is dismissed. The document it makes when it is *not* dismissed
/// exists from that moment, which is why the queueing follows immediately — see
/// <c>DocumentStaging.PickDestinationAsync</c>.
/// </para>
/// </remarks>
private async void OnSaveFile(object? sender, RoutedEventArgs e)
{
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
{
return;
}
if (transfers.SelectedRemoteEntry is not { IsFile: true } row)
{
transfers.Status = "Choose a file on the host to save.";
return;
}
try
{
if (await DocumentStaging.PickDestinationAsync(top, row.Name).ConfigureAwait(true)
is not { } destination)
{
return;
}
transfers.QueueDeliveredDownload(
row,
DocumentStaging.NewStagingPath(row.Name),
path => DocumentStaging.DeliverAsync(destination, path));
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
transfers.Status = $"That file could not be saved: {exception.Message}";
}
}
}