Merge branch 'main' into the vaults screen, and let it rotate keys too

Main built vault key rotation while this branch was reshaping the screen that
would drive it, so the two met in the same three files. Every other conflict was
textual and resolved by taking both; these are the ones where a decision had to
be made.

**The view model.** Main taught TeamsViewModel three things and this branch had
renamed and rewritten it into VaultsViewModel. All three are ported rather than
dropped, because each is a behaviour rather than wording: adding somebody now
wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed,
removing somebody rotates the vault and hands the new key to whoever is left, and
a share reports how many generations were wrapped. The session calls they reach —
ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list
rather than to one vault, and they are called that way here rather than narrowed:
adding somebody is a change to the list, so every vault the list carries is one
they can now fetch. This screen makes lists that carry one vault, so the sentences
name one; where a list carries several, naming them all is the honest report, and
the members section already says the list is shared.

AddMemberAsync ran two lines over the length limit once the sharing was in it, so
the calls behind it moved to AddOrInviteAsync and the three-way refusal to
WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was
before the sharing arrived.

**The tests.** Main's four new cases are ported to the vault-first API, including
the one that matters most: the tampered key log is corrupted *before* the add,
because the add is now a route to a wrap and a test that corrupted it afterwards
would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey
now expects two holders rather than one — main's fake records the creator's own
self-grant, and a key-holder list that omitted it would show the one person who
can certainly open a new vault as somebody who cannot.

**The README.** The limits list is six rather than four or five: main's rotation
entries and this branch's "a vault cannot be deleted" describe different things
and both are true. "The rekey is flagged, never performed" is gone, since it is
now performed, and M3 reads *Done* rather than *Done, except rekey*.

One thing worth writing down that neither side had. An invitation claimed at
sign-in still leaves the key owed, where an add does not: at the moment an
invitation is issued there is no account and no published key to wrap to, and the
claim happens on the invitee's machine, which holds nothing. Manual check 12.1
says so, because a reader who knows adding shares would otherwise read that step
as stale.

1561 tests pass.
This commit is contained in:
2026-08-04 13:58:56 +02:00
51 changed files with 4785 additions and 275 deletions
+124 -1
View File
@@ -1158,7 +1158,11 @@ follows is about what happens the first time it does.
and Bob is now in the members table.
6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
opens.
opens. This is where an invitation differs from an add and the difference is not a defect: adding an
account the directory already knows wraps the key on the spot, because the adding machine has it and
the recipient has a published key to wrap to. An invitation has neither at the moment it is issued —
there is no account yet — and the claim happens on Bob's machine, which holds nothing. So the key is
still owed, and somebody has to hand it over.
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
@@ -1342,3 +1346,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
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.