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:
jaap-jan committed 2026-08-04 10:07:16 +02:00
1 parent 7b7fd7b2ef
commit ebb88c8ae4
13 files changed
+1032 -59

No files matched your search

@@ -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}";
}
}
}