Merge main into the teams branch

Two conflicts, and both were two people counting the same things differently
rather than disagreeing about what the code should do.

PhoneShell's header comment. The branch made "the five hub screens"
numberless, because TEAMS made it six and a number in that sentence had
already gone stale once. Main corrected "three destinations" to "two" in the
same sentence, because giving a shell the whole phone took the terminal out of
the set the header is drawn on. Both are right and neither noticed the other:
the header now stays on the hub's screens and on the two top-level
destinations, which is Hosts and Keychain.

The manual checks. Both sides appended a Phase 10 — main added the software
keyboard and the phone's terminal surface as 10 and 11, the branch added
Teams. Nothing about them overlaps, so the resolution is to keep all three in
the order they were written and renumber Teams to Phase 12, its subsections
and the one cross-reference inside 12.1 with it. Main's two phases keep the
numbers they already carry in its history, since renumbering those would move
headings somebody may already have linked to.

Everything else merged without a conflict, and the two places worth checking
afterwards both held: IsMoreSurface and the first case of
PhoneShell.OnBackRequested each kept ShellScreen.Team alongside main's edits.
Those two are one fact in two places, so a merge that dropped Team from either
would have trapped the user on the teams screen with the MORE tab dark.

Verified after resolving: solution builds with no errors and no new warnings,
the Android head builds, and every suite passes — App 214, Layout 73, Api 162,
Infrastructure 34, Contracts 25, Session 54. App gained the three shell-flow
tests main brought with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:40:27 +02:00
co-authored by Claude Opus 5
15 changed files with 882 additions and 236 deletions
+131 -11
View File
@@ -835,7 +835,127 @@ On the phone's host editor with several tags: the chips are at least 36 tall, sp
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
being squeezed to nothing.
## Phase 10 — Teams: the operations that span two accounts
---
## Phase 10 — The software keyboard and the boxes that take secrets
Every check here needs a real Android device or emulator, and there is no headless equivalent of any of
them: the software keyboard is an inset the platform reports, and a headless top level reports none.
Worth running on two devices if you have them — one on Android 14 or earlier and one on Android 15 or
later — because the interface is kept clear of the keyboard by a different mechanism on each. Before 15 the
activity's `AdjustResize` has the platform shorten the window; from 15 the window is not resized at all and
`PhoneShell` applies the reported inset itself. A build that only ever ran on one of the two will look
correct and be half broken.
### 10.1 The vault passphrase box is treated as a password by the keyboard
Launch to the lock screen, tap the passphrase box, type a few characters.
**Pass:** dots on screen, and **no suggestion strip above the keyboard** — no completions, no previously
typed words, no autocorrect. Then open any ordinary box on the phone (the host search, a snippet's name)
and confirm the suggestions come back there.
**Failure means:** `TextInputOptions.ContentType` is missing — most likely a box was given `PasswordChar`
directly instead of `Classes="... secret"`. `PasswordChar` is what the screen draws; the content type is
what the keyboard is told, and only the second one keeps a passphrase out of the IME's learning
dictionary. A box showing dots with a suggestion strip over it is the worst case, not a cosmetic one.
### 10.2 The keyboard does not cover the box being typed into
The same box: with the keyboard up, the passphrase box and the UNLOCK button under it are both visible.
Repeat on each of the five boxes that take a secret — lock screen, both enrollment boxes, the connect
password on HOSTS, and the connect password on FILES.
**Pass:** the box stays on screen when the keyboard opens, and the interface is shortened rather than slid
— the header stays where it is rather than scrolling off the top.
**Failure means:** on Android 15 or later, the inset is no longer reaching `PhoneShell`. On 14 or earlier,
`WindowSoftInputMode` has been dropped from the activity and the platform is panning the window instead of
resizing it — which, for a window whose whole content is one native view, pans by nothing useful.
### 10.3 Nothing is stranded when the keyboard closes
Dismiss the keyboard with back or the down-chevron from each of those screens.
**Pass:** the interface fills the screen again immediately, with no band of empty canvas left along the
bottom and no scroll position left part way down.
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
event, or the margin is only ever added to.
### 10.4 Rotating with the keyboard up
Focus a passphrase box, then turn the phone sideways.
**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
rotation rather than being recreated, and live shells survive it.
---
## Phase 11 — The phone's terminal surface
Every check here needs a real device for the reason Phase 10's do, plus one of its own: the interesting
question on this screen is whether a native `WebView` composites above what Avalonia draws over it, and no
headless surface has a native view to answer with. `docs/android-port.md` still records that as unverified;
11.2 is the check that settles it.
### 11.1 A shell gets the screen · **the important one for chrome**
Open a shell from HOSTS.
**Pass:** the vault header, the session strip and the four-entry bottom bar are all gone. What is left is
one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
back: all three come back, the tab is still in the strip and its dot is still green.
**Failure means:** one of the three rows is not bound on `IsShowingPages`, or the terminal is being reached
by a route that leaves `Surface` on `Page`.
### 11.2 The connect menu is not drawn over the renderer · **the important one**
With a shell showing output, press `+`.
**Pass:** the terminal's rectangle goes to the canvas colour and the sheet sits over it whole — scrim, three
rows and CANCEL, every one of them tappable, none of them sliced down the left edge. Tap the scrim: the
terminal comes back with its scrollback intact and the shell still running.
**Failure means:** `IsTerminalShowing` is not being cleared by `IsConnectSheetOpen` — or, if the sheet is
sliced *despite* the rectangle going blank, something else in that Panel is still showing. A sheet that
draws over live terminal output is the Android answer to the compositing question, and it means every
future sheet on this surface has to collapse the renderer too.
### 11.3 Back lowers the menu before it leaves the terminal
With the connect menu open, use the system back gesture.
**Pass:** the menu closes and the terminal is still showing. A second back leaves the terminal for the
screen it was opened over.
**Failure means:** the guard in `PhoneShell.axaml.cs` is below the surface check rather than above it, and
one gesture is spending two levels.
### 11.4 The two end buttons cannot be pushed off the bar
Open six or more shells.
**Pass:** the pills scroll under a fixed back arrow and a fixed `+`; neither ever leaves the screen, and
scrolling the pills to either end does not move them.
**Failure means:** a control was put inside the `ScrollViewer` rather than beside it.
### 11.5 The text-size buttons are always reachable
With a shell open, scroll the accessory key row to the far left and the far right.
**Pass:** `A` and `A+` stay pinned at the right-hand end throughout, separated from the keys by the
hairline, and both are at least 38 tall. At the smallest and largest sizes the one that can do nothing is
visibly disabled rather than silently inert.
**Failure means:** they have been folded into the scrolling row — which is the arrangement the connection
line existed to avoid, and the reason it could be removed at all.
---
## Phase 12 — Teams: the operations that span two accounts
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
@@ -850,7 +970,7 @@ second DodoSSH profile means a second machine, a second OS user, or the same mac
Whichever account plays the invitee **must not have signed in to this deployment before** — most of what
follows is about what happens the first time it does.
### 10.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
1. Sign in as `alice`, make a team, and open its invitations.
2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was**
@@ -866,12 +986,12 @@ follows is about what happens the first time it does.
opens.
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
— go to 10.2 rather than reading the invitation code, because the server is doing exactly what it should.
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
Step 6 opening the vault *without* Alice sharing a key would be the far more serious failure: nothing on
the server can wrap a vault key, so an item that decrypts after a membership change alone means a key
reached that machine by a route this architecture says does not exist.
### 10.2 An unverified address claims nothing, and the log is the only place that says so
### 12.2 An unverified address claims nothing, and the log is the only place that says so
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
Invite that address, then sign in as them.
@@ -888,7 +1008,7 @@ can walk into their team. Stop there. If it stays pending after verifying, the c
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
### 10.3 An invitation can be withdrawn until it is taken up
### 12.3 An invitation can be withdrawn until it is taken up
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
@@ -901,7 +1021,7 @@ accepted one that could be unpicked here would be worse: it is a membership now,
revokes their vault key grants and flags every team vault for rekey, which is not what "revoke invitation"
should quietly do.
### 10.4 An address already in the team is refused; an address that merely has an account is not
### 12.4 An address already in the team is refused; an address that merely has an account is not
With Bob in the team, invite `bob@example.com` to it again.
@@ -915,7 +1035,7 @@ hour on his next request rather than at a sign-in, so give it that long before d
endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody
willing to create a team first. See ADR 0009.
### 10.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
Use one account and leave the other idle for two or three hours, then read the members table.
@@ -928,7 +1048,7 @@ user's own overlapping requests failing on a version that moved under them. A va
means the refresh is not running on ordinary requests at all, which is the state that made this column
impossible to offer honestly before.
### 10.6 Ownership changes hands in one act
### 12.6 Ownership changes hands in one act
As the owner, transfer ownership to another active member, then read both rows.
@@ -942,7 +1062,7 @@ member, and to yourself.
either one leaves a team that no client can administer back into shape. If your grants were revoked or the
vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
### 10.7 Archiving is refused while the team owns a vault
### 12.7 Archiving is refused while the team owns a vault
With a team that owns at least one vault, try to archive it.
@@ -953,7 +1073,7 @@ in this product. The team is still in everybody's list afterwards and its vaults
everybody holding a key — including the person who pressed it, quietly, and with nothing in the product
able to put them back.
### 10.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
Make a team that owns no vaults, add the second account to it, invite a third address, and archive it.
@@ -965,7 +1085,7 @@ invitations inside the same transaction exists to prevent, and it would happen w
nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason
an archived team is only restorable by an operator who checks that first.
### 10.9 Renaming a team, and the slug that does not move
### 12.9 Renaming a team, and the slug that does not move
Rename a team and change its description.