Public Access
Merge branch 'claude/vault-creation-sharing-62c0b6'
# Conflicts: # README.md
This commit is contained in:
@@ -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