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:
@@ -0,0 +1,220 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Turns documents chosen in the system picker into ordinary local files the transfer queue can upload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The way in, and the only one this head has.</b> Android has no browsable local filesystem for a
|
||||
/// second pane to show — the decision docs/android-port.md took before any of this was built — so a file
|
||||
/// leaves this phone by being pointed at in the system picker, which hands back a <c>content://</c> URI
|
||||
/// belonging to whichever app owns the document.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Copied rather than streamed, and that is a requirement rather than a shortcut.</b> A document URI has
|
||||
/// no path behind it, its stream is not promised to be seekable, and the grant that opens it can be revoked
|
||||
/// or the document edited while an upload is in flight. <c>FileTransferQueue</c> needs all three of the
|
||||
/// things that costs: a path, a length it can trust, and a seek so a resumed upload starts from the byte
|
||||
/// the last attempt reached. So the document is copied into this application's own cache first and the copy
|
||||
/// is what gets queued — a real file, behaving like every other thing in that queue.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One directory per file, named by a UUIDv7.</b> Two documents chosen in one go can have the same
|
||||
/// display name, and two picks a minute apart certainly can; a shared staging directory would make the
|
||||
/// second copy overwrite the first, which is a data-loss bug that only shows up when somebody uploads two
|
||||
/// files called <c>config</c>. The directory is the uniqueness, so the file inside it can keep the name the
|
||||
/// picker gave it — which is the name the remote end gets, because <c>QueueUploads</c> takes it from the
|
||||
/// path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class DocumentStaging
|
||||
{
|
||||
/// <summary>Everything staged in either direction, under one directory so a sweep is one call.</summary>
|
||||
private static string Root =>
|
||||
Path.Combine(PhoneEnvironment.CacheDirectory, "staging");
|
||||
|
||||
/// <summary>A path in the staging area for a file of this name, with the directory made.</summary>
|
||||
/// <remarks>
|
||||
/// <b>One directory per file, named by a UUIDv7</b> — see the type's own remarks for why the uniqueness
|
||||
/// is the directory rather than the name. Used by both directions: a document copied in for upload, and
|
||||
/// a download on its way out to the document the save picker made.
|
||||
/// </remarks>
|
||||
internal static string NewStagingPath(string? name)
|
||||
{
|
||||
var folder = Path.Combine(Root, Guid.CreateVersion7().ToString("n"));
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
return Path.Combine(folder, SafeName(name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks for documents and copies each one into the cache, returning the paths of the copies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Multiple by design: the queue moves one file at a time, but choosing them is a trip out to another
|
||||
/// application and back, and making somebody take that trip once per file is the kind of thing a phone
|
||||
/// is judged on. An empty list means the picker was dismissed, which is not an error and is reported as
|
||||
/// nothing having happened rather than as a failure.
|
||||
/// </remarks>
|
||||
internal static async Task<IReadOnlyList<string>> PickAsync(
|
||||
TopLevel top,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(top);
|
||||
|
||||
var chosen = await top.StorageProvider
|
||||
.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Files to upload",
|
||||
AllowMultiple = true,
|
||||
})
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (chosen.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var staged = new List<string>(chosen.Count);
|
||||
|
||||
foreach (var document in chosen)
|
||||
{
|
||||
staged.Add(await CopyInAsync(document, cancellationToken).ConfigureAwait(true));
|
||||
}
|
||||
|
||||
return staged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks where a download should end up, and returns the document the picker made.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>ACTION_CREATE_DOCUMENT</c>, which is the only way a file leaves this application: everything else
|
||||
/// on this phone is either the app's own private storage or somewhere it has no permission to write.
|
||||
/// The suggested name is the remote file's, because that is the name the person was looking at when
|
||||
/// they pressed the button, and they can change it in the picker like any other save.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The document exists as soon as this returns</b> — the picker creates it, empty, and a download
|
||||
/// that then fails leaves that empty file behind. Nothing here can prevent it: the alternative is
|
||||
/// raising the picker after the transfer, minutes later, over whatever the person moved on to, and on
|
||||
/// Android often while the application is backgrounded and cannot show one at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static async Task<IStorageFile?> PickDestinationAsync(TopLevel top, string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(top);
|
||||
|
||||
return await top.StorageProvider
|
||||
.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Save file",
|
||||
SuggestedFileName = name,
|
||||
ShowOverwritePrompt = true,
|
||||
})
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Copies a finished download out to the document the picker made.</summary>
|
||||
/// <remarks>
|
||||
/// The write is truncating rather than appending, which matters on a retry: the picker's document is
|
||||
/// created when it is dismissed and a second attempt writes over the empty — or partly written — file
|
||||
/// rather than after it.
|
||||
/// </remarks>
|
||||
internal static async Task DeliverAsync(IStorageFile destination, string localPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
|
||||
var source = new FileStream(
|
||||
localPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true);
|
||||
|
||||
await using (source.ConfigureAwait(false))
|
||||
{
|
||||
var target = await destination.OpenWriteAsync().ConfigureAwait(false);
|
||||
|
||||
await using (target.ConfigureAwait(false))
|
||||
{
|
||||
if (target.CanSeek)
|
||||
{
|
||||
target.SetLength(0);
|
||||
}
|
||||
|
||||
await source.CopyToAsync(target).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes everything left in the staging directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called once at composition, before anything can have queued a transfer, which is what makes deleting
|
||||
/// the lot safe: at that moment nothing in there belongs to a transfer that could still want it. What it
|
||||
/// is for is the residue a stopped upload leaves deliberately — the copy is kept so RESUME has something
|
||||
/// to read — a download whose delivery failed, and whatever a process death left behind mid-copy.
|
||||
/// </remarks>
|
||||
internal static void Sweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Root))
|
||||
{
|
||||
Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// A cache that could not be emptied is not a reason to refuse to start. Android reclaims this
|
||||
// directory itself when the device runs short of storage.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> CopyInAsync(IStorageFile document, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = NewStagingPath(document.Name);
|
||||
|
||||
// Off the interface thread from here down — this is a byte copy of something that can be a hundred
|
||||
// megabytes, and nothing in it touches a control. The caller's own await is what comes back to the
|
||||
// interface thread to queue the result. Hence the two-step disposal: configuring the await on a
|
||||
// using declaration would leave the variable a ConfiguredAsyncDisposable rather than a stream.
|
||||
var source = await document.OpenReadAsync().ConfigureAwait(false);
|
||||
|
||||
await using (source.ConfigureAwait(false))
|
||||
{
|
||||
var target = new FileStream(
|
||||
path, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true);
|
||||
|
||||
await using (target.ConfigureAwait(false))
|
||||
{
|
||||
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The display name reduced to something that can be a file name here and a name on the remote.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A picker's display name comes from whichever application owns the document and is not obliged to be
|
||||
/// a valid file name — it can carry a separator, which without this would write outside the directory
|
||||
/// staging just made, and would then be joined onto the remote path by <c>QueueUploads</c>. Both are
|
||||
/// worth refusing at the one point where the name enters this application.
|
||||
/// </remarks>
|
||||
private static string SafeName(string? name)
|
||||
{
|
||||
var trimmed = Path.GetFileName(name ?? string.Empty).Trim();
|
||||
|
||||
if (trimmed.Length == 0 || trimmed is "." or "..")
|
||||
{
|
||||
return "file";
|
||||
}
|
||||
|
||||
return string.Join('_', trimmed.Split(Path.GetInvalidFileNameChars()));
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,18 @@ internal static class PhoneEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Where this phone keeps copies that only have to survive the thing that made them.</summary>
|
||||
/// <remarks>
|
||||
/// <c>cacheDir</c> — per-app like <see cref="Paths"/>, and unlike it, reclaimable: Android deletes from
|
||||
/// here when the device runs short of storage. That is the right trade for the upload staging in
|
||||
/// <see cref="DocumentStaging"/>, whose files are worthless the moment their transfer finishes, and it
|
||||
/// is why the profile is not kept here. The cost is stated rather than hidden: a file reclaimed under
|
||||
/// storage pressure while its upload is still running fails that upload.
|
||||
/// </remarks>
|
||||
public static string CacheDirectory =>
|
||||
Require().CacheDir?.AbsolutePath
|
||||
?? throw new InvalidOperationException("Android returned no cacheDir for this application.");
|
||||
|
||||
/// <summary>
|
||||
/// The activity currently on screen, or null while the app is backgrounded.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user