Public Access
Merge branch 'claude/vault-creation-sharing-62c0b6'
# Conflicts: # README.md
This commit is contained in:
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,27 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
private readonly FileTransferQueue queue;
|
||||
private readonly Action<Action> post;
|
||||
|
||||
/// <summary>
|
||||
/// Local files that exist only so this queue could move them — see <see cref="QueueStagedUploads"/> and
|
||||
/// <see cref="QueueDeliveredDownload"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Compared case-insensitively because the paths come back through the queue's snapshots rather than
|
||||
/// straight from the caller, and a comparison that a casing round trip could break would leak a file
|
||||
/// per transfer on any head that ever normalises one.
|
||||
/// </remarks>
|
||||
private readonly HashSet<string> staged = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// What to do with a completed download whose real destination this layer cannot write to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a
|
||||
/// download that failed once and succeeded on the second attempt must still end up where the person
|
||||
/// pointed. See <see cref="QueueDeliveredDownload"/>.
|
||||
/// </remarks>
|
||||
private readonly Dictionary<Guid, Func<string, Task>> deliveries = [];
|
||||
|
||||
private VaultViewModel? vault;
|
||||
private VaultKnownHostStore? knownHosts;
|
||||
private IRemoteFileStore? session;
|
||||
@@ -522,6 +543,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
||||
|
||||
/// <summary>Raised on the UI thread whenever a transfer appears or changes state.</summary>
|
||||
/// <remarks>
|
||||
/// For a head that has to tell the operating system what this process is doing — Android's foreground
|
||||
/// service, which must be up for as long as bytes are moving and down afterwards. An event rather than
|
||||
/// letting that head watch <see cref="Transfers"/> itself: the collection announces rows arriving and
|
||||
/// leaving, and the transition that matters most is neither of those but a row going from RUNNING to
|
||||
/// DONE without moving.
|
||||
/// </remarks>
|
||||
internal event EventHandler? ActivityChanged;
|
||||
|
||||
/// <summary>How many transfers are moving or waiting to move.</summary>
|
||||
/// <remarks>
|
||||
/// Queued counts as active. A queue with three files in it and one of them running is a process that
|
||||
/// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose.
|
||||
/// </remarks>
|
||||
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
|
||||
|
||||
internal bool HasTransfers => Transfers.Count > 0;
|
||||
|
||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||
@@ -982,6 +1020,143 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues copies that were made for this upload and belong to nothing else, so they are deleted once
|
||||
/// the transfer no longer needs them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This exists for the phone, and the copy is not an implementation detail that could be avoided.</b>
|
||||
/// Android hands a chosen document over as a <c>content://</c> URI with no path behind it and no promise
|
||||
/// that the stream can be seeked — and this queue seeks, because an upload resumes from the byte the
|
||||
/// last attempt reached. So the head copies the document into the application's own cache first and
|
||||
/// hands over the copy, which is a real file that behaves like every other thing in this queue.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Released on success and on discard, never on failure.</b> A failed or stopped upload is offered a
|
||||
/// RESUME or a RETRY, and both read the local file again — deleting it at the moment it stopped would
|
||||
/// turn one visible failure into a second, stranger one. What is left after a failure is swept at the
|
||||
/// next launch by the head that made it, which is the only place that knows where it put it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void QueueStagedUploads(IReadOnlyList<string> paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
staged.Add(path);
|
||||
}
|
||||
|
||||
QueueUploads(paths);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues one download into a local file this application made, and hands the finished bytes to
|
||||
/// something that knows where they were really meant to go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The mirror of <see cref="QueueStagedUploads"/>, and it exists for the same reason.</b> A phone has
|
||||
/// no directory a download could simply be written into: what the person chose is a document handed back
|
||||
/// by the system's save picker, which this layer cannot open and the queue could not resume against. So
|
||||
/// the transfer runs into the cache like any other, and <paramref name="deliver"/> — supplied by the head
|
||||
/// that raised the picker — copies the result out once there is a result to copy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The destination is chosen before the transfer starts, not after.</b> A picker raised on completion
|
||||
/// would arrive minutes later over whatever the person had moved on to, and on a phone it would often
|
||||
/// arrive while the application is in the background, where Android will not show it at all. The cost is
|
||||
/// stated where a person will meet it: the save picker creates the document when it is dismissed, so a
|
||||
/// download that then fails leaves an empty file where it was pointed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Delivery failure does not delete the bytes.</b> They were fetched over somebody's network and the
|
||||
/// staged copy is all that is left of them; it stays for the next launch's sweep rather than being
|
||||
/// thrown away at the one moment it is worth the most.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="row">The remote file to fetch.</param>
|
||||
/// <param name="localPath">Where to stage it — a path the head owns and will sweep.</param>
|
||||
/// <param name="deliver">Copies the staged file to wherever it was really meant to go.</param>
|
||||
internal void QueueDeliveredDownload(
|
||||
RemoteEntryRowViewModel row,
|
||||
string localPath,
|
||||
Func<string, Task> deliver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
ArgumentNullException.ThrowIfNull(deliver);
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!row.IsFile)
|
||||
{
|
||||
Status = "Only files can be transferred.";
|
||||
return;
|
||||
}
|
||||
|
||||
staged.Add(localPath);
|
||||
deliveries[queue.Enqueue(TransferDirection.Download, localPath, row.FullPath, row.Entry.Length)] =
|
||||
deliver;
|
||||
|
||||
Status = $"Queued {row.Name} for download.";
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Fire-and-forget from the queue's own event, which cannot await: the transfer is over as far as the
|
||||
/// queue is concerned, and what is left is a copy this class owns and a callback the head gave it. The
|
||||
/// status line is the only report either way, which is the same place every other outcome on this screen
|
||||
/// is reported.
|
||||
/// </remarks>
|
||||
private async Task DeliverAsync(string localPath, Func<string, Task> deliver)
|
||||
{
|
||||
var name = Path.GetFileName(localPath);
|
||||
|
||||
try
|
||||
{
|
||||
await deliver(localPath).ConfigureAwait(true);
|
||||
|
||||
Status = $"Saved {name}.";
|
||||
ReleaseStaged(localPath);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = $"{name} was downloaded but could not be saved where you chose: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The directory goes only if it is empty, and that is the whole of the safety here: staging puts one
|
||||
/// file in a directory of its own, so an empty parent is this transfer's and a parent with anything else
|
||||
/// in it is not something this method is entitled to reason about. Failures are ignored rather than
|
||||
/// reported — a cached copy that outlives its transfer is swept at the next launch, and there is nothing
|
||||
/// a person could do with the news.
|
||||
/// </remarks>
|
||||
private void ReleaseStaged(string localPath)
|
||||
{
|
||||
if (!staged.Remove(localPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(localPath);
|
||||
|
||||
if (Path.GetDirectoryName(localPath) is { Length: > 0 } folder)
|
||||
{
|
||||
Directory.Delete(folder);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
||||
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
||||
@@ -1061,6 +1236,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
Transfers.Remove(row);
|
||||
|
||||
// Discarding is the deliberate end of a stopped transfer — the row is gone and with it the
|
||||
// RESUME the staged copy was being kept for, and any delivery that was waiting on it.
|
||||
deliveries.Remove(row.Id);
|
||||
ReleaseStaged(row.Transfer.LocalPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1388,13 +1568,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
||||
post(() =>
|
||||
{
|
||||
// Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
|
||||
// button that will read this file again.
|
||||
if (e.Transfer.State is TransferState.Completed)
|
||||
{
|
||||
// A staged download is not finished when the queue says so — it is finished when the bytes
|
||||
// reach the document the person picked, and only the head can put them there. So the copy
|
||||
// is released by the delivery rather than here, or it would be deleted on the way.
|
||||
if (deliveries.Remove(e.Transfer.Id, out var deliver))
|
||||
{
|
||||
_ = DeliverAsync(e.Transfer.LocalPath, deliver);
|
||||
}
|
||||
else
|
||||
{
|
||||
ReleaseStaged(e.Transfer.LocalPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||
{
|
||||
existing.Transfer = e.Transfer;
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||
}
|
||||
|
||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user