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:
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user