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:
2026-08-04 10:07:16 +02:00
parent 7b7fd7b2ef
commit ebb88c8ae4
13 changed files with 1032 additions and 59 deletions
+22 -12
View File
@@ -74,27 +74,37 @@ public sealed partial class DodoSshApp : Avalonia.Application
workspace.Start();
// Before anything can queue a transfer, which is the only moment at which emptying this is
// provably safe. What it clears is the copy a stopped upload leaves behind on purpose — kept so
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
DocumentStaging.Sweep();
var viewModel = ComposeShell(paths, caches, workspace, knownHosts, connections);
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
//
// Still zero transfers, and the reason moved rather than went away. v2 built the files screen, so
// this head can now browse a remote — but it cannot start a transfer, because both directions need
// the system document picker that scoped storage forces and that is not built (see FilesScreen).
// So the count is zero because the queue provably cannot have anything in it, not because nothing
// was wired. This is still the seam it arrives through: when the picker lands, this reads the
// queue and Refresh() gets called as transfers start and finish.
// The transfer count is real now that the document picker gives this head a way to start one, and
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
// moves five files.
//
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
//
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
// its own, which is the half that would otherwise leave a notification up over nothing.
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing. Without this the notification would come up when an
// upload started and stay up after it finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) };
return new PhoneShell { DataContext = viewModel };
}
/// <remarks>