diff --git a/README.md b/README.md index 50c393b..f9a6604 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,12 @@ that design now; the desktop's own v2 is a 190-pixel labelled sidebar in place o search box in the titlebar, and session tabs as pills. Its light theme is not built — see [`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both. +**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom +bar are collapsed while a shell is up, and a single 52-pixel row replaces them: back on the left, the +sessions as pills, and a `+` on the right offering the three connections this application can make — a +shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers +that menu first if it is open. + Widening the sidebar moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every screen exactly the width it was designed against. diff --git a/docs/android-port.md b/docs/android-port.md index ad638bd..77006e0 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -509,6 +509,14 @@ go at 360dp: drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt believing they typed an `l`. + **The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip + and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen + draws a 52-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three + connections there are. That sheet is the head's first control that could be drawn over the renderer, so + it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's + WebView actually composites above Avalonia content is still the unverified question recorded below, and + collapsing is correct under either answer. + All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7. What is left, in the order it matters: @@ -564,3 +572,16 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi - **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into WebView2 but does not come back — and Android's focus model is different enough that the problem should be confirmed to exist before anything is written to solve it. +- **The software keyboard is kept off the interface in one place, and by two mechanisms.** `PhoneShell` + owns it rather than each screen, because everything the phone draws is inside that one control and a + screen added later would otherwise have to remember. The two mechanisms are not a belt and braces: before + Android 15 the activity's `AdjustResize` has the platform shorten the window and the reported keyboard + inset arrives already consumed, and from Android 15 edge-to-edge is enforced, the window is no longer + resized for the keyboard, and the inset is what there is. Each is dead where the other applies, which is + why the margin is taken from the inset alone — the two added together would strand the interface an + entire keyboard too high. See `docs/manual-checks.md` phase 10, which is the only way either is verified. +- **A box that takes a secret says so twice.** `PasswordChar` is what the screen draws and + `TextInputOptions.ContentType` is what the software keyboard is told, and only the second one turns off + the suggestion strip and keeps the passphrase out of the IME's learning dictionary. The desktop head + needs only the first, which is why the phone's `TextBox.secret` class carries both rather than the two + being set per box. diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 2e6a324..307db2f 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -31,7 +31,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > | v2 element | What ships instead | > | --- | --- | > | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. | -> | `23 ms · fwd 5432` on the terminal's connection line | The account and endpoint actually dialled. There is no RTT measurement in SSH.NET and nothing forwards anything. | +> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 52-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. | > | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. | > | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. | > | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. | diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 53a67a2..dedb6ff 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -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. diff --git a/src/DodoSSH.Client.Android/MainActivity.cs b/src/DodoSSH.Client.Android/MainActivity.cs index a61b308..02cbcbb 100644 --- a/src/DodoSSH.Client.Android/MainActivity.cs +++ b/src/DodoSSH.Client.Android/MainActivity.cs @@ -8,6 +8,7 @@ using DodoSSH.Client.Android.Platform; using global::Android.App; using global::Android.Content; using global::Android.Content.PM; +using global::Android.Views; namespace DodoSSH.Client.Android; @@ -34,12 +35,26 @@ namespace DodoSSH.Client.Android; /// other launch mode answers it with a second copy of this activity on top of the first — which on this /// head would mean a second Avalonia application over a live one. /// +/// +/// AdjustResize is declared rather than left unspecified, and it is half of how this head +/// keeps the software keyboard off the box being typed into; PhoneShell is the other half. Left +/// unspecified, Android chooses, and what it chooses for a window whose entire content is one native view +/// — which is what an Avalonia surface is — is to pan: it slides the window up by however much it thinks +/// the focused *native* view needs, and since that view is the whole surface, the passphrase box goes on +/// sitting under the keyboard. Resizing instead makes the window shorter, which the layout inside it can +/// answer, and a screen built around a ScrollViewer then scrolls the focused box into view by +/// itself. On Android 15 and later this attribute is ignored — edge-to-edge is enforced there and the +/// window is no longer resized for the keyboard — which is precisely the case PhoneShell handles from the +/// reported inset. The two are complementary and never both in effect: where the window resizes, the +/// keyboard inset arrives already consumed and measures zero. +/// /// [Activity( Label = "DodoSSH", Theme = "@style/DodoTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTask, + WindowSoftInputMode = SoftInput.AdjustResize, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.ScreenLayout diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml index d27fcd7..8be44bb 100644 --- a/src/DodoSSH.Client.Android/Theme/Phone.axaml +++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml @@ -320,6 +320,27 @@ + + + - + @@ -93,10 +87,15 @@ + BorderThickness="1" CornerRadius="12" Padding="14,12"> - - - + + + + @@ -185,41 +209,57 @@ v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row saying what the row already looks like. + + On every screen except the one it names. The terminal draws these same sessions in its own bar, and + two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to + stop. Wrapped rather than given a second condition, because the strip's own visibility is about + whether there are any tabs and this one is about which surface is up. --> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - + @@ -230,8 +270,13 @@ - - + - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +