Files
jaap-jan ebb88c8ae4 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.
2026-08-04 10:07:16 +02:00

322 lines
12 KiB
C#

using System.Collections.Concurrent;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using NSubstitute;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// What may be queued for transfer, and what is said about the rest.
/// </summary>
/// <remarks>
/// <para>
/// This is the whole of what drag and drop decides. The handlers on the screen extract paths or rows from a
/// drop and hand them here; every rule about which of them can be moved, which are skipped and what the
/// status line says lives in the view model, where it needs no window.
/// </para>
/// <para>
/// <b>What these cannot cover is the drag itself.</b> Headless Avalonia has no native window and cannot
/// synthesise a platform drag, so a test that pretended to drop a file from the file manager would pass
/// while confirming nothing. The wiring is verified by hand — see <c>docs/manual-checks.md</c> — and what
/// is automated is the half that a person checking by eye would most easily get wrong: the counting.
/// </para>
/// </remarks>
public sealed class TransferQueueingTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-drop-{Guid.CreateVersion7():N}");
/// <summary>What the view model has posted and <see cref="Queued"/> has not run yet.</summary>
/// <remarks>
/// A queue of this test's own, standing in for the dispatcher — which is what it replaces. Draining
/// <c>Dispatcher.UIThread</c> meant depending on which thread a runner happened to touch that
/// process-wide object from first, and once another class got there first every test here died on
/// "the calling thread cannot access this object" having asserted nothing about transfers at all.
/// <para>
/// A queue rather than a poster that runs the action inline, and the difference is not stylistic: the
/// transfer queue raises <c>Changed</c> from its pump thread as well as from the call that enqueued,
/// so inline execution would have a background thread adding rows to an <c>ObservableCollection</c>
/// while the test reads it. Draining keeps every mutation on the thread doing the asserting, which is
/// the one thing the dispatcher was providing that is worth keeping.
/// </para>
/// </remarks>
private readonly ConcurrentQueue<Action> posted = new();
private readonly TransfersViewModel transfers;
public TransferQueueingTests()
{
Directory.CreateDirectory(directory);
transfers = new TransfersViewModel(
Substitute.For<ISftpSessionFactory>(),
TimeProvider.System,
posted.Enqueue);
}
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
/// <remarks>
/// The refusal that has to happen before anything else: there is nowhere to put a file until a host is
/// connected, and a queue that filled up first would start failing the moment one was.
/// </remarks>
[Fact]
public void DroppingFilesWhileDisconnected_QueuesNothingAndSaysWhy()
{
transfers.IsConnected = false;
transfers.QueueUploads([File("one.txt")]);
Queued().ShouldBeEmpty();
transfers.Status.ShouldContain("Connect to a host first");
}
[Fact]
public void DroppingSeveralFiles_QueuesEachOfThem()
{
Connected();
transfers.QueueUploads([File("one.txt"), File("two.txt"), File("three.txt")]);
Queued().Count.ShouldBe(3);
transfers.Status.ShouldContain("3 files");
transfers.Status.ShouldContain("/srv/app");
}
/// <remarks>
/// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored
/// looks exactly like a transfer that failed to start — so it is counted and reported.
/// </remarks>
[Fact]
public void DroppingAFolderAmongFiles_SkipsItAndSaysSo()
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueUploads([File("one.txt"), folder]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 file");
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// The paths in an operating-system drop come from another process and are not obliged to still be
/// right by the time the drop lands.
/// </remarks>
[Fact]
public void DroppingAFileThatHasGone_SkipsItAndSaysSo()
{
Connected();
transfers.QueueUploads([File("one.txt"), Path.Combine(directory, "never-existed.txt")]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 item was no longer there");
}
[Fact]
public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise()
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueUploads([folder]);
Queued().ShouldBeEmpty();
transfers.Status.ShouldContain("Nothing was queued");
transfers.Status.ShouldContain("1 folder was skipped");
}
[Fact]
public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
{
Connected();
transfers.QueueDownloads([RemoteFile("one.log"), RemoteFile("two.log")]);
Queued().Count.ShouldBe(2);
transfers.Status.ShouldContain("2 files");
transfers.Status.ShouldContain("download into");
}
[Fact]
public void DroppingARemoteDirectory_SkipsItAndSaysSo()
{
Connected();
transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// The buttons were the only way to queue anything before drag and drop, and they now go through the
/// same two methods — so there is one set of rules rather than two that have to agree. This is what
/// says they still do.
/// </remarks>
[Fact]
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
{
Connected();
transfers.SelectedRemoteEntry = RemoteFile("one.log");
transfers.DownloadCommand.Execute(null);
Queued().ShouldHaveSingleItem();
// And refuses a directory in the same words, rather than with the button's own message.
transfers.SelectedRemoteEntry = RemoteDirectory("logs");
transfers.DownloadCommand.Execute(null);
Queued().Count.ShouldBe(1);
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// Queueing is reachable from any thread — a drop is handled on the UI thread, a retry is not — and
/// nothing about it may depend on which one. Worth stating because the version of this class that
/// drained <c>Dispatcher.UIThread</c> did depend on exactly that, and said so only by failing in CI on
/// a machine whose scheduling differed. The draining still happens on the test's own thread, as
/// <see cref="Queued"/> explains; what is asserted here is the half that has no business caring.
/// </remarks>
[Fact]
public void QueueingFromAnotherThread_StillEnqueues()
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
Connected();
transfers.QueueUploads([File("one.txt")]);
}
catch (Exception exception)
{
failure = exception;
}
});
thread.Start();
thread.Join();
failure.ShouldBeNull();
Queued().ShouldHaveSingleItem();
}
/// <remarks>
/// The phone's way in, and it has to obey the same rules as every other: a document chosen in the system
/// picker is copied into the cache and the copy is queued, which is an upload with one extra property —
/// that this application made the file and will delete it again. Everything about *what may be queued*
/// is the same, and this says so rather than leaving a second path free to drift.
/// </remarks>
[Fact]
public void StagedUploads_QueueUnderTheSameRulesAsAnyOther()
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueStagedUploads([File("picked.txt"), folder]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 file");
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// <para>
/// The phone's way out. The delivery itself — copying the finished file into the document the save
/// picker made — needs a transfer that actually runs and a picker to have made something, so it is
/// checked by hand in <c>docs/manual-checks.md</c> phase 14. What is worth pinning here is the pair of
/// refusals in front of it, because both would otherwise be discovered as an empty file sitting in
/// somebody's Downloads: the picker creates the destination the moment it is dismissed, so anything
/// this method turns away after that point has already cost a visible artefact.
/// </para>
/// </remarks>
[Fact]
public void ADeliveredDownload_QueuesTheFileAndRefusesADirectory()
{
var delivered = 0;
Connected();
transfers.QueueDeliveredDownload(
RemoteFile("one.log"),
Path.Combine(directory, "staged", "one.log"),
_ =>
{
delivered++;
return Task.CompletedTask;
});
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("one.log");
// A directory has nothing to fetch, and the message is the same one every other path on this screen
// gives for the same mistake.
transfers.QueueDeliveredDownload(
RemoteDirectory("logs"),
Path.Combine(directory, "staged", "logs"),
_ => Task.CompletedTask);
Queued().Count.ShouldBe(1);
transfers.Status.ShouldContain("Only files");
// Nothing is delivered by queueing. The callback runs when the bytes are there and not before.
delivered.ShouldBe(0);
}
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
/// <remarks>
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
/// marshals because the queue raises it from a pump thread. Nothing drains that here, so the posts are
/// run by hand — the alternative is asserting on the status line alone, which is a string this code
/// wrote about itself and proves nothing about anything having been enqueued.
/// </remarks>
private IReadOnlyList<TransferRowViewModel> Queued()
{
while (posted.TryDequeue(out var action))
{
action();
}
return transfers.Transfers;
}
private void Connected()
{
transfers.IsConnected = true;
transfers.RemotePath = "/srv/app";
transfers.LocalPath = directory;
}
private string File(string name)
{
var path = Path.Combine(directory, name);
System.IO.File.WriteAllText(path, "contents");
return path;
}
private static RemoteEntryRowViewModel RemoteFile(string name) => new(
new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.File, 128, DateTimeOffset.UnixEpoch, "-rw-r--r--"));
private static RemoteEntryRowViewModel RemoteDirectory(string name) => new(
new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.Directory, 0, DateTimeOffset.UnixEpoch, "drwxr-xr-x"));
}