From f5ffd1983d8768688dc02e10248d1794d1229d9d Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Mon, 3 Aug 2026 15:26:47 +0200 Subject: [PATCH] Make Connections the place a connection is made, and put the keychain away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes to the phone, and the last one needed the connect path taking apart. **The bottom bar is three entries.** The keychain moved onto the hub, which is now SETTINGS with a gear rather than MORE with a hamburger. A bottom bar is for the places a session moves between, and keys, credentials and tags are managed occasionally and then left alone — which is the shape of everything already behind that hub. With the keychain on it, "more" stopped being a description of what is there. `ShellScreen.Vault` joining `IsMoreSurface` is the whole of the change: the tab that lights, the header that stands down and the back gesture's first case all read that one property, which is why the switch mirrors it by construction rather than by a second list. The keychain screen grew the header every hub screen has, because the shell's own is not above it any more and without one there would be no back arrow and nothing saying what the list is. The desktop keeps its Keychain rail entry. A rail with nine slots has room, so this is the second thing the two heads arrange deliberately differently, after the hub itself. **Terminal became Connections**, and the word does more work than a rename usually does — see below. The enum member stays `ShellSurface.Terminal`, for the reason the tab was never called Vault: the surface is a terminal, and the word a user reads is the product's. **The + puts the software keyboard away.** It sits above a terminal somebody is typing into, so the sheet it raises was arriving underneath a keyboard covering the half of the screen the sheet is on — and worse, laid out into the strip left above it, since the keyboard's inset shortens everything this head draws. Avalonia cannot do this and it is worth knowing why: `TopLevel.InputPane` reports the keyboard and offers nothing that closes one, because the framework's model is that it belongs to whatever has focus — and this keyboard was raised by the `WebView`'s own text input, by a native view Avalonia's focus manager never owned. Clearing Avalonia's focus leaves it exactly where it is. So `Platform/SoftKeyboard.cs` asks `InputMethodManager`, off the decor view's window token, and every step of it is allowed to be absent. **With nothing open, Connections is a connect screen rather than an empty state.** A box taking `user@host` or `user@host:port`, a password, and the machines most recently connected to underneath. The box is the only path in this product to a machine the keychain has never heard of, which is a real case it had no answer for: an address somebody was handed five minutes ago. A typed password and nothing else — offering the keychain's keys would be a second binding resolution beside `TryBuildAuthentication`, and the argument against a second one is written there at length. Nothing typed is saved, and the screen says so: a machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name. The recents come out of the vault's own connection log rather than a list kept in this process, so they survive a restart and arrive on a new phone with the keychain. Deduplicated by address, because this is a list of places and not of events, and capped at six so the box stays above the keyboard. Emptied when the vault is — they are decrypted entries naming where somebody works, and a lock that left them on screen would be a list still readable after every key that decrypted it was zeroed. Tapping one leads to whichever of two things it is: a keychain host goes to that host's connect bar, where its key, its password box and its refusals already live, and an address goes back into the box, without the password, whose absence is the point of that path rather than a gap in it. **The connect path was shaped like `HostRowViewModel` all the way down.** The log entry, the identification, the failure record and the retry all took a row. They take a four-field `ConnectionTarget` now, so a connection to an address shares the ladder of refusals, the host-key question and the tab's lifecycle rather than growing a second copy of them. `ConnectionRecorder.Record` and `Identify` have always taken a nullable host id, so the log could already hold a connection with no item behind it. One behavioural change falls out of that and it is the one to know about: **trusting a host key now retries the attempt that raised the question** instead of re-running whichever host is selected. That was correct while a selected host was the only way to connect; with a manual target it would dial a different machine, or refuse with "choose a host first" over a key the user has just agreed to trust. The test selects a host first, so a regression cannot pass by connecting to the wrong thing successfully. `LogsViewModel.ReloadAsync` split so the connections half can be read alone. Reading the keychain's activity for a screen that offers neither would double the decryption on the list that was already the expensive one. Twelve tests: the parse grammar as a theory over seven refusals, the dialled request, the retry, and both branches of tapping a recent row. The recents rows are built by hand rather than connected-and-closed — what those tests are about is which branch a row takes, and driving it through the recorder's queue would test the recorder, which `DodoSSH.Client.Session.Tests` already does. What needs a device is phases 11.6 to 11.9 of `docs/manual-checks.md`. --- README.md | 19 +- docs/android-port.md | 31 +- docs/design-import-gaps.md | 11 +- docs/manual-checks.md | 51 ++++ .../Platform/SoftKeyboard.cs | 50 ++++ .../Views/KeychainScreen.axaml | 29 +- .../Views/MoreScreen.axaml | 48 ++- .../Views/PhoneShell.axaml | 55 ++-- .../Views/PhoneShell.axaml.cs | 4 +- .../Views/TerminalScreen.axaml | 111 ++++++- .../Views/TerminalScreen.axaml.cs | 26 ++ .../ViewModels/LogsViewModel.cs | 43 ++- .../ViewModels/MainWindowViewModel.cs | 146 ++++++++- .../ViewModels/VaultViewModel.cs | 282 ++++++++++++++++-- .../ShellFlowTests.cs | 216 ++++++++++++++ 15 files changed, 1028 insertions(+), 94 deletions(-) create mode 100644 src/DodoSSH.Client.Android/Platform/SoftKeyboard.cs diff --git a/README.md b/README.md index 3cf0049..7f37934 100644 --- a/README.md +++ b/README.md @@ -268,11 +268,20 @@ the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read t host-key decisions and the counted delete confirmations are there too, and none of them were softened to fit 360dp. -Its interface is the **v2 design**: four destinations in a bottom bar — Hosts, Terminal, Keychain and More -— with snippets, SFTP, S3 buckets, logs and preferences one tap deeper behind the last. Both heads are on -that design now; the desktop's own v2 is a 190-pixel labelled sidebar in place of the icon rail, a centred -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. +Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the +last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP, +S3 buckets, logs and preferences behind Settings. A bottom bar is for the places a session moves between, +and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is a +190-pixel labelled sidebar in place of the icon rail, a centred search box in the titlebar, and session tabs +as pills, and it keeps its Keychain entry — a rail with nine slots has room. Its light theme is not built — +see [`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both. + +**Connections is where a connection is made, not only where one is shown.** With nothing open it offers a +box taking `user@host` or `user@host:port` and a password, and lists the machines most recently connected +to underneath. That box is the one path in the product to a machine the keychain has never heard of — the +case somebody has just been handed an address — and nothing typed into it is saved: a machine worth keeping +belongs on Hosts, where it can carry a key, a group's defaults and a name. Tapping a recent machine goes to +its host if it has one and back into the box if it does not. **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 35-pixel row replaces them: back on the left, the diff --git a/docs/android-port.md b/docs/android-port.md index d9242fe..2adb52d 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -493,11 +493,18 @@ go at 360dp: **Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the - view models that were already shared, behind a MORE hub that holds the five destinations the bottom bar - has no room for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved - together, because it is shared. What v2 asked for and did not get is in + view models that were already shared, behind a hub that holds the destinations the bottom bar has no room + for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together, + because it is shared. What v2 asked for and did not get is in [`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is named as absent rather than drawn, because nothing in the SSH layer forwards anything. + + **The bar has since gone from four entries to three.** The keychain joined the hub, which is now called + SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and + managing keys is not one of them. `IsMoreSurface` gained `ShellScreen.Vault`, which is the whole of it — + the membership test, the tab that lights and the back gesture's first case all read that one property. + The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange + deliberately differently, after the hub itself. 8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never reflows a terminal while output is arriving. @@ -516,6 +523,24 @@ go at 360dp: WebView actually composites above Avalonia content is still the unverified question recorded below, and collapsing is correct under either answer. + **The + also puts the software keyboard away, and that needed the platform.** `TopLevel.InputPane` + reports the keyboard and cannot close one — Avalonia's model is that it belongs to whatever has focus, so + the supported dismissal is to move focus — and the keyboard over a terminal was raised by the `WebView`'s + own text input, by a native view Avalonia's focus manager never owned. Clearing Avalonia's focus does + nothing to it. `Platform/SoftKeyboard.cs` asks `InputMethodManager` directly, off the decor view's window + token, and every step of it is allowed to be absent. Without it the sheet arrives under a keyboard + covering the half of the screen the sheet is on — and worse, laid out into what is left, since the + keyboard's inset shortens everything this head draws. + + **With nothing open the surface is a connect screen rather than an empty state**, which is why the bar + calls it Connections. It offers a `user@host[:port]` box with a password and the machines most recently + connected to, out of the vault's connection log. The connect path in `VaultViewModel` was shaped like + `HostRowViewModel` all the way down; it now hangs off a four-field `ConnectionTarget`, so the ladder of + refusals, the host-key question, the log entry and the tab's lifecycle are shared rather than copied. The + host-key retry replays the attempt that raised the question instead of re-running whichever host was + selected — which was correct while a selected host was the only way to connect, and would now dial the + wrong machine. + 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: diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 9492bba..25f42fb 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -6,10 +6,18 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > ## The second import — v2, phone > > A later design in the same project, **DodoSSH Android v2**, is what the phone head now draws: eight -> screens, a bottom bar of four with the rest behind MORE, and a new palette. It has been imported, and +> screens, a bottom bar with the rest behind a hub, and a new palette. It has been imported, and > this section is the same accounting for it. The desktop's own v2 has **not** been imported — only its > palette, because the palette is shared and the two heads may not disagree about a colour. > +> **The bar is three rather than the design's four, and the hub is SETTINGS rather than MORE.** The design +> puts Hosts, Terminal, Keychain and More along the bottom. The keychain moved behind the hub: a bottom bar +> is for the places a session moves between, and keys, credentials and tags are managed occasionally and +> then left alone, which is the shape of everything already behind it. With the keychain on it the hub is no +> longer "more" of anything — it is where everything that is not connecting to a machine lives — so it takes +> the name and the glyph that has meant exactly that on a phone since before this application existed. +> Terminal became **Connections** in the same pass, for a reason below. +> > **The palette went from green to blue, and one colour changed meaning rather than value.** Green used to > be the accent, so `Ellipse.dot.live` filled with `Accent` and "the thing to press" and "a shell is open > here" were the same colour by construction. v2 makes the accent `#5B8CFF` and keeps a green, `#3DDC97`, @@ -37,6 +45,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > | 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`. | > | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. | > | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. | +> | TERMINAL's empty state: a sentence saying nothing is open | ◆ **More than the design asked for, which is rare enough on this list to say out loud.** The tab is called **Connections** and with nothing running the screen is where one is made: a box taking `user@host` or `user@host:port` with a password, and the machines most recently connected to underneath it, read from the vault's own connection log. The box is the only path in the product to a machine that is not in the keychain — the design assumes every connection starts from a saved host, and the case it has no answer for is an address somebody was handed five minutes ago. Nothing typed there is saved, and the screen says so. | > > ## The desktop's v2 > diff --git a/docs/manual-checks.md b/docs/manual-checks.md index f6e0c67..f2d1f5e 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -952,3 +952,54 @@ 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. + +### 11.6 The keyboard goes away with the connect menu + +With a shell open, tap into the terminal so the software keyboard comes up, then press `+`. + +**Pass:** the keyboard drops and the sheet sits on the bottom of the full screen, all four of its rows +reachable. Repeat on Android 14 or earlier and on 15 or later if you have both — the keyboard shortens this +head's interface by a different mechanism on each, and a sheet laid out into the strip left above a +keyboard is the failure this exists to catch. + +**Failure means:** `SoftKeyboard.Hide` found no activity, no input-method manager, or the wrong window +token. Note that Avalonia cannot do this for us — `TopLevel.InputPane` reports the keyboard and cannot close +one, and the keyboard here belongs to the `WebView`, so clearing Avalonia's focus is not a fallback. + +### 11.7 Connections, with nothing open + +Close every tab, then press Connections in the bottom bar. + +**Pass:** a box reading `user@host or user@host:port`, a password box under it, CONNECT, and — if this +keychain has ever connected to anything — a RECENT list underneath. The whole thing scrolls with the +keyboard up, and the password box shows dots with **no suggestion strip** above the keyboard. + +Type a machine you can reach and press CONNECT: a tab appears in the bar and the shell opens. Type +something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning +colour, with no tab and nothing dialled. + +**Failure means:** for the suggestion strip, the box lost `Classes="secret"` — see 10.1, and it is the same +seriousness here, since this password is typed rather than stored and so is typed often. + +### 11.8 A recent machine leads to the right place + +With at least one keychain host and one machine connected to by hand in the log, open Connections with +nothing running. + +**Pass:** tapping the keychain one lands on HOSTS with that host selected and its connect bar up. Tapping +the hand-typed one puts its address back in the box on this screen and leaves the password box **empty**. +Neither one dials anything on its own. + +**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should +not have. If the password came back, something is storing it — it never was. + +### 11.9 Settings holds the keychain, and back walks out of it + +From HOSTS: Settings, then Keychain. + +**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing. +The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back, +by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS. + +**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first +case, and those two have to move together — the switch mirrors that property by construction. diff --git a/src/DodoSSH.Client.Android/Platform/SoftKeyboard.cs b/src/DodoSSH.Client.Android/Platform/SoftKeyboard.cs new file mode 100644 index 0000000..c79782b --- /dev/null +++ b/src/DodoSSH.Client.Android/Platform/SoftKeyboard.cs @@ -0,0 +1,50 @@ +using global::Android.Content; +using global::Android.Views.InputMethods; + +namespace DodoSSH.Client.Android.Platform; + +/// +/// Puts the software keyboard away. +/// +/// +/// +/// There is no Avalonia way to do this, and it is worth knowing why rather than assuming. +/// TopLevel.InputPane reports the keyboard — its state and the rectangle it occludes, which is what +/// PhoneShell keeps the interface clear of — and offers nothing that closes it. The framework's model +/// is that the keyboard belongs to whatever has focus, so the supported way to dismiss one is to move focus +/// off the text box that raised it. +/// +/// +/// That model does not reach the case this exists for. The keyboard over a terminal was raised by the +/// WebView's own text input, inside the page, by a native view Avalonia's focus manager does not own. +/// Clearing Avalonia's focus leaves it exactly where it is, because Avalonia never had it. So the request +/// goes to the platform that does own it. +/// +/// +/// HideSoftInputFromWindow needs a window token, and any attached view's will do — they all belong to +/// the same window. The decor view is the one guaranteed to exist for as long as the activity does. +/// +/// +/// Every step is allowed to be absent and none of them is an error: there is no activity while the app is +/// backgrounded, no input-method manager on a system image without one, and no keyboard up most of the time. +/// A method whose whole contract is "if a keyboard is showing, stop showing it" has nothing to report when +/// one is not. +/// +/// +internal static class SoftKeyboard +{ + public static void Hide() + { + if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not { } view) + { + return; + } + + if (view.Context?.GetSystemService(Context.InputMethodService) is not InputMethodManager manager) + { + return; + } + + manager.HideSoftInputFromWindow(view.WindowToken, HideSoftInputFlags.None); + } +} diff --git a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml index b0bf8ba..5342a69 100644 --- a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml @@ -1,6 +1,7 @@ @@ -26,12 +27,30 @@ Mode=OneWay on the chips is load-bearing: a two-way selection binding moves the chip before the command beside it has had the chance to refuse, which would leave the row lit for a section the list is not on. + + ── v3 ──────────────────────────────────────────────────────────────────────────────────────────────── + This screen moved behind SETTINGS, so it draws its own header — the one every hub screen has. It is not + a decoration: the shell's header stands down behind the hub, and without a row of its own there would be + no back arrow and nothing on screen saying what this list is. --> - + + + + + + + + + + + + + + + + + +