Merge branch 'claude/vault-creation-sharing-62c0b6'
ci / build and test (push) Successful in 1m38s
ci / android head (push) Failing after 9s
ci / api image (push) Successful in 28s

# Conflicts:
#	README.md
This commit is contained in:
2026-08-04 10:10:40 +02:00
13 changed files with 1035 additions and 63 deletions
@@ -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>