Public Access
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:
@@ -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