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:
@@ -279,6 +279,11 @@ jobs:
|
|||||||
# Packaging rather than only compiling, because the two failures this head is most exposed to
|
# Packaging rather than only compiling, because the two failures this head is most exposed to
|
||||||
# are both link-time: a native library with no android ABI, and a managed assembly that
|
# are both link-time: a native library with no android ABI, and a managed assembly that
|
||||||
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
||||||
|
#
|
||||||
|
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
|
||||||
|
# docs/adr/0010-android-distribution.md puts the release key on a machine that is not a runner,
|
||||||
|
# because a signing key reachable from a workflow is a key held by everyone who can change one.
|
||||||
|
# This APK is a build check. It is not something anybody installs.
|
||||||
- name: package
|
- name: package
|
||||||
run: >
|
run: >
|
||||||
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
||||||
|
|||||||
@@ -186,10 +186,15 @@ signing in again brings it all back; the count in the confirmation is the one th
|
|||||||
else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine
|
else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine
|
||||||
that is not yours, sign out there too.
|
that is not yours, sign out there too.
|
||||||
|
|
||||||
Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password
|
**Neither a password nor a passphrase has to be typed twice**, and both ways out of that are opt-in. A
|
||||||
authentication asks for the password every time, because nothing in the interface can create a vault
|
password typed to connect is typed once: tick **Remember this password** under the box and it is saved to
|
||||||
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
|
your keychain and bound to that host the moment the remote accepts it — or add one outright with **+
|
||||||
launch, because no device key is registered.
|
PASSWORD** on the Vault screen. And unlock can be a Windows confirmation instead of the passphrase:
|
||||||
|
Preferences → *This machine* → **REGISTER** keeps this machine's device key in the TPM, so a later launch
|
||||||
|
offers **USE WINDOWS HELLO** on the unlock card. A machine with no TPM — and any desktop that is not
|
||||||
|
Windows — is offered neither button and keeps asking for the passphrase, which Preferences says out loud
|
||||||
|
rather than leaving you to notice. The passphrase never stops working either way: a declined confirmation
|
||||||
|
leaves the box exactly where it was.
|
||||||
|
|
||||||
### Moving files
|
### Moving files
|
||||||
|
|
||||||
@@ -360,8 +365,20 @@ design's blue-tinted `#E3E7F4`, and the type scale is a point larger.
|
|||||||
|
|
||||||
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
|
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
|
||||||
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
|
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
|
||||||
put in one — moving a file *in* from the phone needs the system document picker and is the next piece of
|
put in one. So the way in is **ADD FILES**, which is the system document picker: point at a document
|
||||||
work rather than a thing the screen pretends to do. Hosts and groups are made and corrected here now, from
|
wherever it lives and it goes to the directory showing, rather than choosing on the left and pressing an
|
||||||
|
arrow. What Android hands back is a `content://` URI with no path behind it and no promise of a seek, so
|
||||||
|
the document is copied into the app's own cache and the copy is what the queue moves — which is what lets
|
||||||
|
a stopped upload resume from where it stopped. The copy is deleted when the transfer finishes, kept while
|
||||||
|
it is stopped so RESUME has something to read, and swept at the next launch.
|
||||||
|
|
||||||
|
**SAVE FILE is the way back out**, and it is the system's save picker for the same reason: there is nowhere
|
||||||
|
this application could put a file that you would then be able to open. You choose where it goes before the
|
||||||
|
transfer starts, the download runs into the cache, and the finished bytes are copied out to the document you
|
||||||
|
chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the
|
||||||
|
picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The
|
||||||
|
alternative is a picker that appears minutes later over whatever you moved on to — and often while the app
|
||||||
|
is in the background, where Android will not show one at all. Hosts and groups are made and corrected here now, from
|
||||||
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
|
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
|
||||||
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
|
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
|
||||||
created on the desktop and sync down, and the phone will delete an item — behind the same counted
|
created on the desktop and sync down, and the phone will delete an item — behind the same counted
|
||||||
@@ -506,10 +523,15 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
host key approved at that shell's prompt reaching the second machine as well. See
|
host key approved at that shell's prompt reaching the second machine as well. See
|
||||||
[End-to-end verification](#end-to-end-verification).
|
[End-to-end verification](#end-to-end-verification).
|
||||||
|
|
||||||
Known gaps in the client, stated rather than implied by the interface: nothing in the interface can create
|
The two gaps this milestone shipped with have both closed since. A vault credential can be created — from
|
||||||
a vault credential yet, so password authentication still asks for the password each time — SSH keys *are*
|
the Vault screen, or from the REMEMBER tick beside the connect password, which saves it and binds the host
|
||||||
editable, and binding one to a host is the way to connect without typing anything; and no device key is
|
to it once the remote has accepted — so password authentication asks once rather than every time. And a
|
||||||
registered, so the passphrase is needed on every launch until the OS keystore is wired.
|
device key is registered where the machine can hold one: Windows keeps it in the TPM under a CNG policy
|
||||||
|
that makes the consent dialog a condition of *using* the key rather than a prompt this application draws,
|
||||||
|
which is stronger than [ADR 0007](docs/adr/0007-device-key-protection.md) originally described and is why
|
||||||
|
that ADR was corrected. What is left is the floor rather than a gap: a machine with no TPM, or a desktop
|
||||||
|
that is not Windows, gets a store that reports itself unavailable and keeps asking for the passphrase —
|
||||||
|
the honest answer rather than a degraded one.
|
||||||
|
|
||||||
Host key trust *is* in the vault, which is what makes trust-on-first-use worth having: a fingerprint
|
Host key trust *is* in the vault, which is what makes trust-on-first-use worth having: a fingerprint
|
||||||
approved on one machine is approved on all of them and survives a restart, and the server cannot drop a
|
approved on one machine is approved on all of them and survives a restart, and the server cannot drop a
|
||||||
@@ -594,7 +616,13 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault
|
authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault
|
||||||
key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey,
|
key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey,
|
||||||
which is a much larger act than the one being asked for.
|
which is a much larger act than the one being asked for.
|
||||||
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
- **M4 — hardening and ops**, packaging, self-hosting guide. *Decided ahead of the work, because the first
|
||||||
|
release takes it irreversibly:* who signs the client and where it comes from.
|
||||||
|
[ADR 0010](docs/adr/0010-android-distribution.md) puts the release key with the project rather than with
|
||||||
|
a store, and rules out the arrangement a self-hosted product reaches for by default — the deployment
|
||||||
|
serving the client binary, which hands it to the one party the whole trust model is about. An installed
|
||||||
|
Android app can only ever be updated by a package signed with the same key, so this is the first
|
||||||
|
release's decision to make and nobody else's afterwards.
|
||||||
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
|
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# ADR 0010 — Distributing the Android client, and who holds the release key
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-03
|
||||||
|
- Builds on: [ADR 0001](0001-e2ee-trust-model.md)
|
||||||
|
- Settles: the second open question in [`docs/android-port.md`](../android-port.md#still-open)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[ADR 0001](0001-e2ee-trust-model.md) ends on the hole it cannot close with cryptography: **an operator
|
||||||
|
who wants the secrets attacks the client, not the crypto**, and what that costs is *release signing with
|
||||||
|
a key not held by the server*, and eventually reproducible builds. Until now that sentence had nothing
|
||||||
|
to bind to. The desktop head is not packaged at all — packaging is M4 — and the Android head is a debug
|
||||||
|
artefact: CI runs `-t:SignAndroidPackage` with no keystore, so it is signed with the debug key .NET for
|
||||||
|
Android falls back to when `AndroidKeyStore` is false, and that APK is a build check rather than
|
||||||
|
something anyone installs.
|
||||||
|
|
||||||
|
The first release changes that, and it does so **irreversibly**, which is why this is decided here
|
||||||
|
rather than at upload time. Two Android facts make it a one-way door:
|
||||||
|
|
||||||
|
- **An installed app can only be updated by a package signed with the same key.** The signing key is
|
||||||
|
the app's identity for its whole life; changing it means every existing user uninstalls first, losing
|
||||||
|
their local cache and re-enrolling. (v3 signature rotation exists, but the lineage has to be created
|
||||||
|
*before* it is needed, by the key it is rotating away from.)
|
||||||
|
- **A new app on Google Play must ship as an App Bundle, which means Play App Signing**, so Google
|
||||||
|
generates and holds the key that signs what users install; the developer holds an upload key only.
|
||||||
|
There is no un-enrolling. For an app that already exists outside Play, the only way to keep one
|
||||||
|
package id across both channels is to *hand Google the existing key*.
|
||||||
|
|
||||||
|
So "publish on Play" and "hold our own key" are not two settings. They are two package identities, and
|
||||||
|
the first release picks one.
|
||||||
|
|
||||||
|
The third party changes but the shape does not: **whoever can sign an update can ship one person a
|
||||||
|
build that copies the passphrase.** The vault's encryption is irrelevant to that attack — the client is
|
||||||
|
where the plaintext is, by construction (ADR 0001, `Connect` cannot be a security boundary). So this ADR
|
||||||
|
is about *which* parties are in that position, not about removing them, and there are three candidates:
|
||||||
|
the deployment operator, DodoTech, and Google.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**DodoTech holds the release key, the deployment never serves the client, and Play is a separate
|
||||||
|
decision that has not been taken.**
|
||||||
|
|
||||||
|
1. **One release key, held by the project, kept offline, and never in CI.** Release signing is a
|
||||||
|
deliberate manual step on a machine that is not a runner. CI keeps doing exactly what it does now —
|
||||||
|
packaging with the debug key to catch link-time failures — and must never gain a keystore secret or
|
||||||
|
an `AndroidKeyStore=true`. A signing key in CI is a key held by whoever can push a workflow file,
|
||||||
|
which for a public repository is a wider set than it looks.
|
||||||
|
|
||||||
|
2. **The APK is published on the project's own release page, and a DodoSSH deployment never distributes
|
||||||
|
it.** This is the refusal that carries the security content, and it is the one a self-hosted product
|
||||||
|
gets wrong by default: a "download the app" link on your own server is convenient, obvious, and hands
|
||||||
|
the client binary to the exact party ADR 0001 models as the adversary. The operator may tell people
|
||||||
|
where to get it. They may not be the place it comes from.
|
||||||
|
|
||||||
|
The same rule reaches the update path. A version check pointed at the deployment lets the operator
|
||||||
|
pin a chosen user to a known-vulnerable build by withholding the answer — a weaker attack than
|
||||||
|
signing one, and available without any key at all. If an update check is ever added it points at the
|
||||||
|
project's domain, and the first release simply has none: the release page is the channel and the
|
||||||
|
README says so.
|
||||||
|
|
||||||
|
3. **Play App Signing is not entered, and cannot be entered by accident.** Not because Google is a worse
|
||||||
|
custodian than DodoTech — on the mechanics it is a better one, since the key lives in Google's
|
||||||
|
infrastructure rather than on a laptop, and the reach and auto-update story is not close. It is
|
||||||
|
declined because of what it costs *this* product specifically: the buyers named in ADR 0001 are teams
|
||||||
|
who refuse to put infrastructure credentials in a SaaS, and telling them the client that holds their
|
||||||
|
plaintext is signed by a key the vendor cannot see is the same answer they already rejected, one
|
||||||
|
layer down. A targeted signed build compelled by a lawful order or produced from a compromised
|
||||||
|
console account is the archetype of the attack ADR 0001 calls the largest practical hole.
|
||||||
|
|
||||||
|
Deferring is cheap and reversing is not, so the default falls the deferrable way. Revisiting is a
|
||||||
|
second ADR, and it has two honest exits: hand Google the existing key and keep one identity, or take
|
||||||
|
a distinct package id and accept two apps. Both are worse decisions to discover than to take.
|
||||||
|
|
||||||
|
4. **Reproducible builds are the goal that makes all of the above matter less, and they are not
|
||||||
|
achievable today.** A build a third party can reproduce from source turns the signing key from a
|
||||||
|
trusted authority into a convenience — anyone can check that the published APK is the published
|
||||||
|
source. .NET for Android is not there: dex output, AOT images and archive timestamps are not
|
||||||
|
bit-reproducible across machines in practice. It stays the standing goal ADR 0001 names, recorded
|
||||||
|
here as the thing that would let point 3 be reconsidered on the merits rather than on custody.
|
||||||
|
|
||||||
|
5. **F-Droid is not a channel.** Its build server compiles from source and signs with its own key, which
|
||||||
|
would be a genuinely better transparency story — but it has no support for a .NET workload plus an
|
||||||
|
Android SDK toolchain, and this head needs both. Not refused; unavailable.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**The reach cost is real and should not be talked down.** Installing means enabling installation from
|
||||||
|
the browser or file manager, per source, on Android 8 and later — a permission the platform frames as
|
||||||
|
dangerous, correctly. There is no discovery, no automatic update, and no Play channel for a corporate
|
||||||
|
MDM to deploy from, which for a product sold to teams is the sharpest edge of this decision. What
|
||||||
|
partially answers it is that this client is installed by people who already run their own identity
|
||||||
|
provider and their own vault server; sideloading is not the strangest thing they will do that week.
|
||||||
|
|
||||||
|
**The key becomes a single point of failure with no recovery.** Losing it means existing installs can
|
||||||
|
never be updated again — not a bad update, *no* update — and the only way out is a new package id and a
|
||||||
|
manual migration. It is therefore backed up offline in more than one place, and a v3 rotation lineage is
|
||||||
|
created at the first release rather than at the first emergency, because a lineage can only be signed by
|
||||||
|
the key it replaces.
|
||||||
|
|
||||||
|
**The attack ADR 0001 names is narrowed, not removed.** DodoTech can still ship one user a malicious
|
||||||
|
build. What changes is that the deployment operator — the party the threat model is actually about, and
|
||||||
|
the one with a motive to read their own team's credentials — cannot, and that a compelled or breached
|
||||||
|
third-party store is not in the path either. That is the whole of what this decision buys, and it is
|
||||||
|
worth stating at that size rather than larger.
|
||||||
|
|
||||||
|
**M4's desktop packaging inherits rule 2 and not the rest.** Windows and macOS have no equivalent of
|
||||||
|
Play App Signing in the mandatory sense: Authenticode and Developer ID both leave the private key with
|
||||||
|
the developer, and Apple's notarization is a scan rather than a signature over the shipped binary. The
|
||||||
|
custody question is therefore easy there; the "not served by the deployment" rule is the part that
|
||||||
|
carries over, and it carries over unchanged.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- **Play as the primary channel, sideloading as the fallback.** This is the arrangement most Android
|
||||||
|
products land on, and it does not survive contact with rule 2's reasoning: it is the same shape —
|
||||||
|
the binary that holds the plaintext arriving through a party who can be compelled — with a larger and
|
||||||
|
better-resourced party in the middle. Better mechanics, same class.
|
||||||
|
- **Shipping the APK from the DodoSSH server it will talk to**, so a new phone gets the client from the
|
||||||
|
deployment it is enrolling against. Genuinely the nicest onboarding available, and it makes the
|
||||||
|
operator the distributor of the client that holds their team's credentials. Refused outright, and
|
||||||
|
named here because it will be proposed again.
|
||||||
|
- **A release key held by CI so tagging cuts a release.** The convenience is the point of CI and the
|
||||||
|
key is the point of this ADR; where they collide the key wins. Signing an artefact is one command, run
|
||||||
|
rarely, by a person.
|
||||||
+44
-13
@@ -28,7 +28,9 @@ verified is that it compiles, links, packages, and carries the right natives.
|
|||||||
|
|
||||||
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
|
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
|
||||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||||
is **one remote pane** with Android's document picker for moving files in and out.
|
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
||||||
|
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
||||||
|
with the foreground service now counting transfers as well as shells.
|
||||||
|
|
||||||
**What was actually checked**, so the rest can be read with the right amount of trust:
|
**What was actually checked**, so the rest can be read with the right amount of trust:
|
||||||
|
|
||||||
@@ -132,7 +134,9 @@ mean the transfer queue's local half (`LocalDirectory`, the drive list, the brea
|
|||||||
code.
|
code.
|
||||||
|
|
||||||
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
|
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
|
||||||
seam holds two very different remotes, and a `Uri`-backed Android document would be a third.
|
seam holds two very different remotes, and a `Uri`-backed Android document would be a third. (**⚠️ That last
|
||||||
|
clause is wrong, and the build is what corrected it**: a document URI cannot resume, so what shipped stages a
|
||||||
|
copy and hands over a path. See the note under the decision below.)
|
||||||
|
|
||||||
**Decided: one remote pane and the document picker**, and out of the first scope. See
|
**Decided: one remote pane and the document picker**, and out of the first scope. See
|
||||||
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
|
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
|
||||||
@@ -256,6 +260,24 @@ and `IRemoteFileStore` both carry over unchanged. Phase 6 already put a bucket b
|
|||||||
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
|
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
|
||||||
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
|
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
|
||||||
|
|
||||||
|
> **⚠️ Corrected by the build. Both directions shipped, and neither as a third `IRemoteFileStore`.** A document
|
||||||
|
> URI cannot sit behind that interface honestly: the queue seeks, because an upload resumes from the byte the
|
||||||
|
> last attempt reached, and a `content://` stream promises no seek, no stable length, and no grant that
|
||||||
|
> survives the document being edited underneath it. So `DocumentStaging` copies the chosen document into the
|
||||||
|
> application's cache and hands the queue an ordinary path — which cost one class in the head and *nothing*
|
||||||
|
> in the shared layers, rather than a third implementation of a seam and every resume rule rewritten to cope
|
||||||
|
> with a stream that cannot rewind. The copy is deleted when the transfer completes, kept while it is stopped
|
||||||
|
> so RESUME can read it, and swept at the next launch.
|
||||||
|
>
|
||||||
|
> **Outbound is the mirror image, with one decision the inbound half did not have to take: when to ask.** The
|
||||||
|
> save picker is raised *before* the transfer, so `QueueDeliveredDownload` runs into the same staging
|
||||||
|
> directory and hands the finished bytes to a callback the head supplied. Asking afterwards would put the
|
||||||
|
> picker minutes away from the button that caused it and, on a phone, frequently in the background — where
|
||||||
|
> Android will not show one. The cost is that `ACTION_CREATE_DOCUMENT` creates its file when it is
|
||||||
|
> dismissed, so a download that then fails leaves an empty one; that is said on the screen and in the
|
||||||
|
> README rather than left to be found. A delivery that fails keeps the staged bytes for the sweep instead of
|
||||||
|
> deleting the one copy of something that was just fetched over somebody's network.
|
||||||
|
|
||||||
### Sessions survive backgrounding, via a foreground service
|
### Sessions survive backgrounding, via a foreground service
|
||||||
|
|
||||||
A persistent notification for as long as a shell or a transfer is live.
|
A persistent notification for as long as a shell or a transfer is live.
|
||||||
@@ -562,9 +584,11 @@ What is left, in the order it matters:
|
|||||||
|
|
||||||
- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
|
- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
|
||||||
ever been launched on hardware or an emulator. Everything below is reasoning from the code.
|
ever been launched on hardware or an emulator. Everything below is reasoning from the code.
|
||||||
- **The document picker.** The files screen browses a remote, downloads from it and runs the queue; putting
|
- ~~**The save picker — the other half of file transfer.**~~ **Built**, along with the half before it: both
|
||||||
a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped
|
pickers go through Avalonia's storage provider and both stage through the cache. See the correction under
|
||||||
source, which is the decision recorded above and the obvious next piece of work.
|
the decision above for the shape and for the one thing about it a person will notice — an empty file where
|
||||||
|
a failed download was pointed. What is *not* built is a folder picker for several downloads at once: the
|
||||||
|
save picker names one destination, so SAVE FILE takes the selected row.
|
||||||
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
|
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
|
||||||
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
|
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
|
||||||
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
||||||
@@ -575,7 +599,9 @@ What is left, in the order it matters:
|
|||||||
|
|
||||||
## Still open
|
## Still open
|
||||||
|
|
||||||
Neither of these blocks the spike, and both want answering before there is anything to release.
|
Neither of these blocked the spike, and both wanted answering before there was anything to release. Both
|
||||||
|
now have answers, kept here under the questions that produced them — the heading stays because what is
|
||||||
|
worth reading is which way each went and why, not that the list is empty.
|
||||||
|
|
||||||
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
|
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
|
||||||
|
|
||||||
@@ -591,13 +617,18 @@ Neither of these blocks the spike, and both want answering before there is anyth
|
|||||||
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
|
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
|
||||||
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
|
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
|
||||||
in 30.
|
in 30.
|
||||||
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
|
- ~~**How it is distributed, and what that does to the supply-chain story.**~~ **✅ Settled in
|
||||||
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
|
[ADR 0010](adr/0010-android-distribution.md): the project holds the release key, the deployment never
|
||||||
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
|
serves the client, and Play is deferred.** The question was whether Play App Signing — Google generating
|
||||||
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
|
and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR
|
||||||
but it is a change to a documented security property of this product, and it should be reasoned about in
|
takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play
|
||||||
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
|
App Signing, an installed app can only be updated by a package signed with the same key, so the first
|
||||||
costs reach.
|
release picks one identity for good.
|
||||||
|
|
||||||
|
Two things follow for this head *now*, before there is anything to release. CI keeps signing with the
|
||||||
|
debug key and must never gain a keystore secret. And no download link for the APK may ever be served by a
|
||||||
|
DodoSSH deployment — that hands the client binary to the party the threat model is about, which is a
|
||||||
|
worse arrangement than either of the two the question was originally between.
|
||||||
|
|
||||||
## Smaller things, decided by default
|
## Smaller things, decided by default
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. |
|
> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. |
|
||||||
> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. |
|
> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. |
|
||||||
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
|
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
|
||||||
> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`, and `LocalPath` starts at `SpecialFolder.UserProfile`, which on Android is the application's own private directory: a download would report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. |
|
> | SFTP: the transfer card, and moving files at all | ◆ **Both directions ship, and neither is the button the design drew.** There is no local pane for an UPLOAD arrow to point away from, so the gestures are the platform's two pickers: ADD FILES is `ACTION_OPEN_DOCUMENT` — "point at a document wherever it lives" — and SAVE FILE is `ACTION_CREATE_DOCUMENT`, asked *before* the transfer because a picker raised on completion arrives over whatever the person moved on to, and often while the app is backgrounded and Android will not show one. Both stage through the cache, for the same reason: a `content://` URI has no path, no trustworthy length and no promised seek, and the queue needs all three to resume. `QueueDownloads` — the desktop's path, writing into `LocalPath` — is unused here, because `LocalPath` on Android is the app's own private directory. The queue is drawn now, since this head can fill it. |
|
||||||
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
|
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
|
||||||
> | A `+` floating button on **HOSTS** | **Shipped in v3.** It was omitted twice, on the grounds that it would open a host editor this head had not got — and that was true until one existed. It raises a sheet rather than a form, because "add" on this screen is two operations: a host, or a group to file hosts under. |
|
> | A `+` floating button on **HOSTS** | **Shipped in v3.** It was omitted twice, on the grounds that it would open a host editor this head had not got — and that was true until one existed. It raises a sheet rather than a form, because "add" on this screen is two operations: a host, or a group to file hosts under. |
|
||||||
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
|
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
|
||||||
|
|||||||
@@ -1302,3 +1302,122 @@ rather than two. There is no confirmation prompt, deliberately.
|
|||||||
|
|
||||||
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
||||||
half that matters when the handset is the thing that was lost.
|
half that matters when the handset is the thing that was lost.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 14 — Moving files to and from the phone's remote
|
||||||
|
|
||||||
|
Every check here needs a real Android device or emulator, a host with SFTP or a bucket, and at least one
|
||||||
|
document on the phone to send. What is automated is what can be: `TransferQueueingTests` says a staged
|
||||||
|
upload obeys the same rules as any other and that a delivered download refuses a directory before the
|
||||||
|
picker's damage is done. What cannot be automated is everything below — the two system pickers are another
|
||||||
|
application, and the staging copies, the delivery, the notification and the resume are all things only a
|
||||||
|
running phone does.
|
||||||
|
|
||||||
|
### 14.1 ADD FILES opens the system picker, and takes more than one
|
||||||
|
|
||||||
|
Connect to a host on SFTP, navigate somewhere writable, press **ADD FILES**, and choose two documents in one
|
||||||
|
go — long-press to multi-select in Android's picker.
|
||||||
|
|
||||||
|
**Pass:** two rows appear in the queue with the names the picker showed, and both land in the directory the
|
||||||
|
breadcrumb names. The pane's listing shows them after **↻**.
|
||||||
|
|
||||||
|
**Failure means:** one row from a two-document pick is `PickAsync` losing the rest, and two rows with one
|
||||||
|
name is the per-file staging directory having gone — that is the overwrite `DocumentStaging` documents, and
|
||||||
|
it silently uploads the same bytes twice.
|
||||||
|
|
||||||
|
### 14.2 The name that arrives is the name that was picked · **the one most likely to be wrong**
|
||||||
|
|
||||||
|
Pick a document whose display name has a space and a non-Latin character in it, and one from a cloud
|
||||||
|
provider — Drive, or the Downloads shortcut — rather than local storage.
|
||||||
|
|
||||||
|
**Pass:** the file on the host is called what the picker called it. A cloud document uploads too, or fails
|
||||||
|
with the provider's own message in the status line rather than a crash.
|
||||||
|
|
||||||
|
**Failure means:** a mangled name is `SafeName` over-reaching. A name that reaches the host with a `/` in it
|
||||||
|
is `SafeName` under-reaching, and that one writes to a path nobody chose. A cloud document that hangs is
|
||||||
|
the copy being made on the interface thread — the whole reason `CopyInAsync` leaves it.
|
||||||
|
|
||||||
|
### 14.3 The queue is bounded, and the buttons stay reachable
|
||||||
|
|
||||||
|
Queue five or six files at once, on a small phone if there is one.
|
||||||
|
|
||||||
|
**Pass:** the queue scrolls inside its own region and **ADD FILES**, **DELETE** and **CLOSE** are all still
|
||||||
|
on screen. Every button is a thumb's size.
|
||||||
|
|
||||||
|
**Failure means:** buttons pushed off the bottom is the `MaxHeight` gone from the queue's scroller, and it
|
||||||
|
makes the screen unusable exactly when somebody has queued the most work.
|
||||||
|
|
||||||
|
### 14.4 A stopped upload resumes rather than starting again
|
||||||
|
|
||||||
|
Start a large upload, press **STOP** part way, then press **RESUME**.
|
||||||
|
|
||||||
|
**Pass:** it carries on from roughly where it stopped rather than from zero — the progress text is the thing
|
||||||
|
to read.
|
||||||
|
|
||||||
|
**Failure means:** restarting from zero means the staged copy was deleted at the stop, which is precisely
|
||||||
|
what `QueueStagedUploads` does not do and why it does not. A failure saying the file cannot be found is the
|
||||||
|
same bug, one step further along.
|
||||||
|
|
||||||
|
### 14.5 The copies do not accumulate · **the one nothing else would catch**
|
||||||
|
|
||||||
|
Note the app's storage in Android Settings → Apps → DodoSSH → Storage. Upload a large file, let it finish,
|
||||||
|
and look again. Then stop an upload part way, leave it stopped, force-stop the app and relaunch it.
|
||||||
|
|
||||||
|
**Pass:** storage returns to about what it was after the successful upload — the copy is deleted the moment
|
||||||
|
the transfer completes. After the stopped one, the cache is bigger while the app stays open (the copy is
|
||||||
|
being kept for RESUME) and back to its old size after the relaunch, which is `DocumentStaging.Sweep`.
|
||||||
|
|
||||||
|
**Failure means:** growth after a successful upload is `ReleaseStaged` not firing, and every file sent
|
||||||
|
leaves a second copy on the phone until Android reclaims the cache. Growth that survives a relaunch is the
|
||||||
|
sweep not running.
|
||||||
|
|
||||||
|
### 14.6 The notification is up while it transfers, and gone afterwards
|
||||||
|
|
||||||
|
Queue several files in each direction, put the phone to sleep with the screen off, and wait.
|
||||||
|
|
||||||
|
**Pass:** the foreground notification is up, the transfers finish while the screen is off, and the
|
||||||
|
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
|
||||||
|
is what it was already for.
|
||||||
|
|
||||||
|
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
||||||
|
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
||||||
|
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire.
|
||||||
|
|
||||||
|
### 14.7 SAVE FILE writes where you pointed it, and the file opens
|
||||||
|
|
||||||
|
Select a file on the host — something with a viewer, an image or a PDF — press **SAVE FILE**, and put it
|
||||||
|
somewhere reachable: Downloads, or a folder in Drive. When the transfer finishes, open it from the phone's
|
||||||
|
own Files app.
|
||||||
|
|
||||||
|
**Pass:** the status line says it was saved, the file is where the picker was pointed under the name shown
|
||||||
|
there, and it opens with the right contents. The queue row says DONE.
|
||||||
|
|
||||||
|
**Failure means:** a row that says DONE with nothing at the destination is `DeliverAsync` never running —
|
||||||
|
the delivery is registered per transfer id, and losing it makes the download look like a success while the
|
||||||
|
bytes sit in a cache nobody can reach. A file that is there but empty or truncated is the copy out, not the
|
||||||
|
transfer: check the `SetLength(0)` and that the write stream is being disposed before the status is written.
|
||||||
|
|
||||||
|
### 14.8 The button is dead until a file is chosen, and refuses a directory
|
||||||
|
|
||||||
|
With nothing selected, look at **SAVE FILE**. Then select a directory row.
|
||||||
|
|
||||||
|
**Pass:** disabled in both cases — it needs a connected remote and a selected *file*, which is the desktop's
|
||||||
|
own `CanDownload`.
|
||||||
|
|
||||||
|
**Failure means:** an enabled button over a directory reaches `QueueDeliveredDownload`'s refusal, which is
|
||||||
|
the right answer arriving too late: the save picker has already created an empty file, so the person is left
|
||||||
|
with a file they did not want and a message saying nothing happened.
|
||||||
|
|
||||||
|
### 14.9 A download that fails leaves the empty file it warned about
|
||||||
|
|
||||||
|
Point SAVE FILE at a destination for a large file, then break the transfer — turn off Wi-Fi and mobile data
|
||||||
|
while it runs.
|
||||||
|
|
||||||
|
**Pass:** the row goes to FAILED with the reason, the status line does not claim it was saved, and there is
|
||||||
|
an empty file at the destination. Reconnect, press **RETRY**, and the same destination fills in — the
|
||||||
|
delivery survives the failure because it is held against the transfer rather than the attempt.
|
||||||
|
|
||||||
|
**Failure means:** a retry that succeeds but leaves the destination empty is the delivery having been
|
||||||
|
dropped on the failure. An error saying the staged file is missing is the copy having been deleted at the
|
||||||
|
stop, which is what `QueueDeliveredDownload` documents it does not do.
|
||||||
|
|||||||
@@ -74,27 +74,37 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
|
|
||||||
workspace.Start();
|
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
|
// 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.
|
// 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
|
// The transfer count is real now that the document picker gives this head a way to start one, and
|
||||||
// this head can now browse a remote — but it cannot start a transfer, because both directions need
|
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
|
||||||
// the system document picker that scoped storage forces and that is not built (see FilesScreen).
|
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
|
||||||
// So the count is zero because the queue provably cannot have anything in it, not because nothing
|
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
|
||||||
// was wired. This is still the seam it arrives through: when the picker lands, this reads the
|
// moves five files.
|
||||||
// queue and Refresh() gets called as transfers start and finish.
|
//
|
||||||
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
// 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
|
// 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.
|
// because it is subscribed to the workspace, which lives as long as the process.
|
||||||
//
|
var keepAlive = new SessionKeepAlive(
|
||||||
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
|
workspace,
|
||||||
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
|
||||||
// its own, which is the half that would otherwise leave a notification up over nothing.
|
|
||||||
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
|
// 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();
|
keepAlive.Refresh();
|
||||||
|
|
||||||
return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) };
|
return new PhoneShell { DataContext = viewModel };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// The activity currently on screen, or null while the app is backgrounded.
|
/// The activity currently on screen, or null while the app is backgrounded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -19,17 +19,28 @@
|
|||||||
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
|
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
|
||||||
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
|
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
|
||||||
|
|
||||||
◆ **And that is why neither DOWNLOAD nor UPLOAD is on this screen.** Both commands exist and both work;
|
◆ **ADD FILES is the way in, and it is the system document picker rather than an UPLOAD button.** There
|
||||||
what they work *against* is the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`,
|
is nothing local to select from, so the gesture cannot be "choose on the left, press the arrow": it is
|
||||||
and `LocalPath` starts at `LocalDirectory.Home` — `SpecialFolder.UserProfile`, which on Android is the
|
"point at a document wherever it lives, and it goes to the directory showing". What Android hands back
|
||||||
application's own private directory. A download would report success and put the file somewhere the
|
is a `content://` URI, so `DocumentStaging` copies it into this application's cache and queues the copy —
|
||||||
person who asked for it cannot open it, which is worse than not offering it: a refusal is visible and a
|
the queue needs a path, a length and a seek, and a document URI promises none of the three. See that
|
||||||
file in `/data/user/0/…` is not. The way in and out is the system document picker, which is the shape
|
class for why the copy is a requirement rather than a shortcut, and `QueueStagedUploads` for when it is
|
||||||
docs/android-port.md decided on and is the next piece of work.
|
deleted again.
|
||||||
|
|
||||||
So what ships is browsing a remote, and the two remote-side operations that need nothing local —
|
◆ **SAVE FILE is the way out, and it is the save picker rather than a DOWNLOAD button.** `QueueDownloads`
|
||||||
opening a directory and deleting. The queue is drawn because a transfer can still be running when this
|
writes to `Path.Combine(LocalPath, name)`, and `LocalPath` on Android is the application's own private
|
||||||
screen is opened; it is simply not something this head can start yet.
|
directory — a download that way would report success and leave the file where the person who asked for
|
||||||
|
it cannot open it. So this head does not use it: `QueueDeliveredDownload` runs the transfer into the
|
||||||
|
cache and hands the finished bytes to the document `ACTION_CREATE_DOCUMENT` made.
|
||||||
|
|
||||||
|
The destination is chosen *before* the transfer, which is a decision with a visible cost — the picker
|
||||||
|
creates the document when it is dismissed, so a download that then fails leaves an empty file where it
|
||||||
|
was pointed. The alternative is a picker raised minutes later, over whatever the person moved on to and
|
||||||
|
frequently while this application is backgrounded, where Android will not show one at all.
|
||||||
|
|
||||||
|
So what ships is browsing a remote and moving files both ways, plus the two remote-side operations that
|
||||||
|
need nothing local — opening a directory and deleting. The queue is drawn under the actions, because
|
||||||
|
this head can now fill it.
|
||||||
|
|
||||||
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
|
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
|
||||||
and it makes its own trust decision — the host records a second login. So this screen carries its own
|
and it makes its own trust decision — the host records a second login. So this screen carries its own
|
||||||
@@ -255,6 +266,70 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
||||||
|
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
||||||
|
phone is how a screen becomes unusable rather than merely tall.
|
||||||
|
-->
|
||||||
|
<ScrollViewer MaxHeight="164" IsVisible="{Binding HasTransfers}"
|
||||||
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="14,*,Auto" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12" Text="{Binding Arrow}"
|
||||||
|
VerticalAlignment="Center" Foreground="{StaticResource AccentText}" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Margin="8,0" Spacing="3">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Name}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<ProgressBar Height="3" Minimum="0" Maximum="100" Value="{Binding Percent}"
|
||||||
|
Foreground="{StaticResource Accent}"
|
||||||
|
Background="{StaticResource Raised}" />
|
||||||
|
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One button per row, never two: whichever of the three applies to the state it is in.
|
||||||
|
A phone row has space for a name, a bar and one 44-pixel target, and the three are
|
||||||
|
mutually exclusive by construction — IsRunning and CanRetry cannot both hold.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
||||||
|
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="STOP"
|
||||||
|
IsVisible="{Binding IsRunning}"
|
||||||
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="{Binding RetryLabel}"
|
||||||
|
IsVisible="{Binding CanRetry}"
|
||||||
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Button Classes="secondary" Height="40" Content="CLEAR FINISHED" HorizontalAlignment="Stretch"
|
||||||
|
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The two directions, on their own row above the pair below — because DELETE is the button on this
|
||||||
|
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses
|
||||||
|
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this
|
||||||
|
screen exists for on a phone.
|
||||||
|
|
||||||
|
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the
|
||||||
|
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||||
|
a file is selected and something is connected — and is reused rather than restated here.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||||
|
IsEnabled="{Binding CanDownload}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
||||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||||
@@ -263,18 +338,11 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||||
Text="Copying files to and from this phone needs the system document picker, which is not built yet — see the note at the top of this screen. Browsing, opening and deleting work." />
|
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||||
|
|
||||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||||
Text="{Binding Status}" />
|
Text="{Binding Status}" />
|
||||||
|
|
||||||
<!--
|
|
||||||
There is no queue on this screen, and that follows from the note at the top rather than being a
|
|
||||||
separate decision: nothing here can enqueue a transfer, so a queue would be a region that is
|
|
||||||
empty for every possible state of the application. It comes back with the document picker, along
|
|
||||||
with the two buttons that would fill it.
|
|
||||||
-->
|
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Avalonia.Input;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
using DodoSSH.Client.Android.Platform;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Views;
|
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.
|
/// fires after the list has moved its selection, which is what lets this read it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A file is left selected rather than opened. There is nothing this head could do with it — see the
|
/// A file is left selected rather than downloaded, and that is deliberate now rather than forced: the
|
||||||
/// note about the document picker at the top of the screen — and the actions below act on the selection.
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
||||||
@@ -40,4 +42,93 @@ internal sealed partial class FilesScreen : UserControl
|
|||||||
transfers.OpenRemoteCommand.Execute(null);
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,27 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
private readonly FileTransferQueue queue;
|
private readonly FileTransferQueue queue;
|
||||||
private readonly Action<Action> post;
|
private readonly Action<Action> post;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local files that exist only so this queue could move them — see <see cref="QueueStagedUploads"/> and
|
||||||
|
/// <see cref="QueueDeliveredDownload"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared case-insensitively because the paths come back through the queue's snapshots rather than
|
||||||
|
/// straight from the caller, and a comparison that a casing round trip could break would leak a file
|
||||||
|
/// per transfer on any head that ever normalises one.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly HashSet<string> staged = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What to do with a completed download whose real destination this layer cannot write to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a
|
||||||
|
/// download that failed once and succeeded on the second attempt must still end up where the person
|
||||||
|
/// pointed. See <see cref="QueueDeliveredDownload"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly Dictionary<Guid, Func<string, Task>> deliveries = [];
|
||||||
|
|
||||||
private VaultViewModel? vault;
|
private VaultViewModel? vault;
|
||||||
private VaultKnownHostStore? knownHosts;
|
private VaultKnownHostStore? knownHosts;
|
||||||
private IRemoteFileStore? session;
|
private IRemoteFileStore? session;
|
||||||
@@ -522,6 +543,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Raised on the UI thread whenever a transfer appears or changes state.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For a head that has to tell the operating system what this process is doing — Android's foreground
|
||||||
|
/// service, which must be up for as long as bytes are moving and down afterwards. An event rather than
|
||||||
|
/// letting that head watch <see cref="Transfers"/> itself: the collection announces rows arriving and
|
||||||
|
/// leaving, and the transition that matters most is neither of those but a row going from RUNNING to
|
||||||
|
/// DONE without moving.
|
||||||
|
/// </remarks>
|
||||||
|
internal event EventHandler? ActivityChanged;
|
||||||
|
|
||||||
|
/// <summary>How many transfers are moving or waiting to move.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Queued counts as active. A queue with three files in it and one of them running is a process that
|
||||||
|
/// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose.
|
||||||
|
/// </remarks>
|
||||||
|
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
|
||||||
|
|
||||||
internal bool HasTransfers => Transfers.Count > 0;
|
internal bool HasTransfers => Transfers.Count > 0;
|
||||||
|
|
||||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||||
@@ -982,6 +1020,143 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues copies that were made for this upload and belong to nothing else, so they are deleted once
|
||||||
|
/// the transfer no longer needs them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This exists for the phone, and the copy is not an implementation detail that could be avoided.</b>
|
||||||
|
/// Android hands a chosen document over as a <c>content://</c> URI with no path behind it and no promise
|
||||||
|
/// that the stream can be seeked — and this queue seeks, because an upload resumes from the byte the
|
||||||
|
/// last attempt reached. So the head copies the document into the application's own cache first and
|
||||||
|
/// hands over the copy, which is a real file that behaves like every other thing in this queue.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Released on success and on discard, never on failure.</b> A failed or stopped upload is offered a
|
||||||
|
/// RESUME or a RETRY, and both read the local file again — deleting it at the moment it stopped would
|
||||||
|
/// turn one visible failure into a second, stranger one. What is left after a failure is swept at the
|
||||||
|
/// next launch by the head that made it, which is the only place that knows where it put it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal void QueueStagedUploads(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
staged.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueUploads(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues one download into a local file this application made, and hands the finished bytes to
|
||||||
|
/// something that knows where they were really meant to go.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The mirror of <see cref="QueueStagedUploads"/>, and it exists for the same reason.</b> A phone has
|
||||||
|
/// no directory a download could simply be written into: what the person chose is a document handed back
|
||||||
|
/// by the system's save picker, which this layer cannot open and the queue could not resume against. So
|
||||||
|
/// the transfer runs into the cache like any other, and <paramref name="deliver"/> — supplied by the head
|
||||||
|
/// that raised the picker — copies the result out once there is a result to copy.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The destination is chosen before the transfer starts, not after.</b> A picker raised on completion
|
||||||
|
/// would arrive minutes later over whatever the person had moved on to, and on a phone it would often
|
||||||
|
/// arrive while the application is in the background, where Android will not show it at all. The cost is
|
||||||
|
/// stated where a person will meet it: the save picker creates the document when it is dismissed, so a
|
||||||
|
/// download that then fails leaves an empty file where it was pointed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Delivery failure does not delete the bytes.</b> They were fetched over somebody's network and the
|
||||||
|
/// staged copy is all that is left of them; it stays for the next launch's sweep rather than being
|
||||||
|
/// thrown away at the one moment it is worth the most.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="row">The remote file to fetch.</param>
|
||||||
|
/// <param name="localPath">Where to stage it — a path the head owns and will sweep.</param>
|
||||||
|
/// <param name="deliver">Copies the staged file to wherever it was really meant to go.</param>
|
||||||
|
internal void QueueDeliveredDownload(
|
||||||
|
RemoteEntryRowViewModel row,
|
||||||
|
string localPath,
|
||||||
|
Func<string, Task> deliver)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
ArgumentNullException.ThrowIfNull(deliver);
|
||||||
|
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
Status = "Connect to a host first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.IsFile)
|
||||||
|
{
|
||||||
|
Status = "Only files can be transferred.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
staged.Add(localPath);
|
||||||
|
deliveries[queue.Enqueue(TransferDirection.Download, localPath, row.FullPath, row.Entry.Length)] =
|
||||||
|
deliver;
|
||||||
|
|
||||||
|
Status = $"Queued {row.Name} for download.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Fire-and-forget from the queue's own event, which cannot await: the transfer is over as far as the
|
||||||
|
/// queue is concerned, and what is left is a copy this class owns and a callback the head gave it. The
|
||||||
|
/// status line is the only report either way, which is the same place every other outcome on this screen
|
||||||
|
/// is reported.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task DeliverAsync(string localPath, Func<string, Task> deliver)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(localPath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await deliver(localPath).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = $"Saved {name}.";
|
||||||
|
ReleaseStaged(localPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
Status = $"{name} was downloaded but could not be saved where you chose: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The directory goes only if it is empty, and that is the whole of the safety here: staging puts one
|
||||||
|
/// file in a directory of its own, so an empty parent is this transfer's and a parent with anything else
|
||||||
|
/// in it is not something this method is entitled to reason about. Failures are ignored rather than
|
||||||
|
/// reported — a cached copy that outlives its transfer is swept at the next launch, and there is nothing
|
||||||
|
/// a person could do with the news.
|
||||||
|
/// </remarks>
|
||||||
|
private void ReleaseStaged(string localPath)
|
||||||
|
{
|
||||||
|
if (!staged.Remove(localPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(localPath);
|
||||||
|
|
||||||
|
if (Path.GetDirectoryName(localPath) is { Length: > 0 } folder)
|
||||||
|
{
|
||||||
|
Directory.Delete(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
||||||
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
||||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
||||||
@@ -1061,6 +1236,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
||||||
{
|
{
|
||||||
Transfers.Remove(row);
|
Transfers.Remove(row);
|
||||||
|
|
||||||
|
// Discarding is the deliberate end of a stopped transfer — the row is gone and with it the
|
||||||
|
// RESUME the staged copy was being kept for, and any delivery that was waiting on it.
|
||||||
|
deliveries.Remove(row.Id);
|
||||||
|
ReleaseStaged(row.Transfer.LocalPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1388,13 +1568,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
||||||
post(() =>
|
post(() =>
|
||||||
{
|
{
|
||||||
|
// Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
|
||||||
|
// button that will read this file again.
|
||||||
|
if (e.Transfer.State is TransferState.Completed)
|
||||||
|
{
|
||||||
|
// A staged download is not finished when the queue says so — it is finished when the bytes
|
||||||
|
// reach the document the person picked, and only the head can put them there. So the copy
|
||||||
|
// is released by the delivery rather than here, or it would be deleted on the way.
|
||||||
|
if (deliveries.Remove(e.Transfer.Id, out var deliver))
|
||||||
|
{
|
||||||
|
_ = DeliverAsync(e.Transfer.LocalPath, deliver);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReleaseStaged(e.Transfer.LocalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||||
{
|
{
|
||||||
existing.Transfer = e.Transfer;
|
existing.Transfer = e.Transfer;
|
||||||
return;
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||||
}
|
}
|
||||||
|
|
||||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -217,6 +217,70 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
Queued().ShouldHaveSingleItem();
|
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>
|
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
||||||
|
|||||||
Reference in New Issue
Block a user