Files
DodoSSH/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml
T
jaap-jan aff776cb42
ci / build and test (push) Failing after 3m0s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m56s
Rebuild the terminal's keypad, and fix what a device run showed
Six defects, all found by running this head on an emulator against a local API rather than by reading it.
They are in one commit because two of the files carry several of them and splitting those would mean
hand-editing patches; each is argued at its own site.

── the keypad, which is most of this ───────────────────────────────────────────────────────────────────
It was eleven 30-by-44 keys in a horizontal ScrollViewer, and it was wrong twice over. A pan and a press
begin with the same few pixels of travel, so a thumb coming down at an angle — which is how a thumb comes
down — was read as the start of a pan and the press was never delivered; the row slid slightly instead.
And 30 tall is well under the 48 Android asks for, so the presses that did arrive were hard to aim. The
arrows, the keys pressed most and in sequence, were the ones parked past the right-hand edge.

Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it: they
divide the width evenly and add a second row when the set does not fit. That is not convention for its own
sake — a key whose position is fixed can be hit without looking, which is the whole point of a modifier row
used while reading the output above it.

So: two UniformGrid rows, every key an equal share of the width, nothing scrollable, 40 tall. Every key
kept. Arrows on the lower row where the thumb rests, reordered to ← ↓ ↑ → so the pairs that undo each other
are neighbours. It costs about 51 pixels of terminal, four or five lines, and that is the right trade: the
row that was cheaper did not work.

The keys also gain a pressed state, which they had none of. They are Focusable="False" so that using one
does not take the keyboard off the terminal, and a button that never takes focus never shows the adorner
Fluent would otherwise give it — leaving no feedback at all on a control whose only job is to be pressed.
Their looks move from assignments in the code-behind to Button.hotkey, which is what lets a style say
:pressed, and the Ctrl latch becomes a class rather than two brushes written by hand.

── and five smaller ones ────────────────────────────────────────────────────────────────────────────────
The keyboard's inset was never cleared when the app was resumed without the keyboard it was applied for.
Android takes the keyboard away as the activity stops and Avalonia's input pane raises no StateChanged,
the pane going with the window rather than closing in front of one — so the interface came back holding a
whole keyboard's height of empty canvas. 838 of 2340 pixels on a Pixel 5, on every screen, until something
opened and closed the keyboard. MainActivity now raises PhoneEnvironment.Resumed and PhoneShell re-asks the
pane. It asks rather than assuming zero: a permission prompt or the biometric sheet can hand the activity
back with the keyboard genuinely still up, and flattening the inset there would strand the focused box
under a real one.

TextBlock.detail never wrapped, where .body beside it always has. A block that neither wraps nor trims lays
out at its full single-line width and the parent clips it, with no ellipsis to say anything is missing — so
the add sheet read "A machine to connect to, filed under a group if you" and stopped. Which of the fifty-odd
.detail blocks overflows is a function of width and font scale rather than of the string, so this is a style
rather than an attribute on the handful visibly wrong on one phone. The nine that genuinely want one line
now say NoWrap beside their TextTrimming, which is the pairing that was always meant.

The two port boxes are NumericUpDowns and were the only unthemed controls on this head — Fluent's border and
fill, next to a TextBox.field sharing their row. App.axaml has styled NumericUpDown for the desktop all
along; this head never had one.

Three buttons classed "icon accent" were painting Fluent's own Button.accent, #6275AC, a blue in no palette
here. The desktop overrides that style and this head did not, so the same class read correctly there and not
here.

The host key sheet said "First contact with 10.0.2.2 · 2222 . Nothing in this keychain…" — Avalonia puts a
space between adjacent Runs and the last one began with the full stop. A gap before a full stop, on the one
dialogue whose whole job is to be read carefully before it is answered.

── checks ──────────────────────────────────────────────────────────────────────────────────────────────
manual-checks gains 10.4a and 11.10f. 10.4 already covered dismissing the keyboard with back or the
chevron but not leaving the app with it up, which is the path the inset bug shipped through; 10.4a covers
both directions, since a fix that flattened the inset on every resume would pass one half and fail the
other. 11.10f guards the keypad against a ScrollViewer being put back around it.
2026-08-26 13:28:59 +02:00

152 lines
8.1 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.Android.Views.HostKeySheet"
x:DataType="vm:VaultViewModel">
<!--
Designs 05 and 06 in one control, because they are one decision point with two answers and the shell
should not be able to show both. Which one draws is HasPendingHostKey against HasHostKeyMismatch, and
the view models make those mutually exclusive.
◆ Both are load-bearing, and the difference between them is the most important thing on either screen:
05 offers TRUST AND CONNECT, because first contact is a decision a person is entitled to make.
06 offers no way forward at all. There is no continue button, no "connect anyway", and no gesture that
dismisses it into a connection — the only exits are BACK and the host's own editor. That is deliberate
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
sheet for that reason, it is a full-screen panel.
◆ WHAT IS BEHIND IT IS NO LONGER ALWAYS THE HOST LIST. The shell used to move to HOSTS before letting a
handshake ask this, because the desktop head drew the question as a banner on that screen; both heads draw
it over the surface now, so this is raised over whatever the user was on — including the connect box on the
Connections surface, which is where a machine that is in no keychain at all is dialled from. Nothing here
changes for that, and that is the point: this control never knew which screen it was over. See
MainWindowViewModel.OnVaultConnectionFailed and HostKeyCard.axaml on the desktop.
-->
<Panel>
<!-- ============ 05 UNKNOWN HOST KEY ============ -->
<Panel IsVisible="{Binding HasPendingHostKey}">
<!--
Scrim. Non-interactive by itself: tapping outside must not answer the question. 62% of Canvas,
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
desktop, which carries the same note.
-->
<Border Background="#9E05050A" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="20,18,20,16">
<ScrollViewer MaxHeight="620">
<StackPanel Spacing="0">
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,16" />
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
<!--
◆ The port and the full stop are one Run, and that is the whole of the fix.
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
host and the separator read as a sentence without any of them carrying padding. The trailing
Run began with a full stop, so it got that space too and the sheet said
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
— a gap before the stop, on the one dialogue in this application whose entire job is to be
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
its own so it still gets the space it wants.
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
as a markup extension.
-->
<TextBlock Classes="body" Margin="0,6,0,0">
<Run Text="First contact with" />
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
<Run Text="·" />
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
<Run Text="Nothing in this keychain has approved this key." />
</TextBlock>
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
Text="{Binding PendingHostKey.Algorithm}" />
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="6" Padding="12,11">
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
</Border>
<TextBlock Classes="detail" Margin="0,8,0,0" TextWrapping="Wrap"
Text="Compare with the fingerprint the operator published before trusting." />
<Button Classes="primary" Content="TRUST AND CONNECT" Margin="0,16,0,0"
Command="{Binding TrustHostKeyCommand}" />
<Button Classes="secondary" Content="CANCEL" Margin="0,8,0,0"
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</ScrollViewer>
</Border>
</Panel>
<!-- ============ 06 CHANGED HOST KEY ============ -->
<!--
Opaque and full-screen, not a sheet over the previous screen. There is nothing behind this worth
seeing and nothing behind it worth tapping.
-->
<Border IsVisible="{Binding HasHostKeyMismatch}" Background="{StaticResource Canvas}">
<ScrollViewer>
<StackPanel Margin="22,40,22,24" Spacing="0">
<Border Width="44" Height="44" BorderBrush="{StaticResource Danger}" BorderThickness="1.5"
CornerRadius="6" HorizontalAlignment="Left">
<TextBlock Text="!" Foreground="{StaticResource Danger}" FontFamily="{StaticResource MonoFont}"
FontSize="20" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Classes="title" Text="HOST KEY CHANGED" FontSize="17"
Foreground="{StaticResource Danger}" Margin="0,16,0,0" />
<!--
The whole explanation comes from the view model, which already composes it for the desktop
head — including which host, which dates, and both fingerprints. Rewriting it here would be a
second copy of the most safety-critical sentence in the product.
-->
<SelectableTextBlock Classes="fingerprint" Margin="0,12,0,0" FontSize="11"
Foreground="{StaticResource Text}"
Text="{Binding HostKeyMismatch}" />
<TextBlock Classes="body" Margin="0,16,0,0" Foreground="{StaticResource TextFaint}"
Text="If this server was rebuilt on purpose, open the host's editor and use FORGET HOST KEY, then connect again. There is no way to continue from this screen — that is deliberate." />
<!--
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
where the accent button connects.
◆ IT SAID "BACK TO HOSTS", AND IT NEITHER WENT THERE NOR CAME BACK. Two things were wrong with
it at once. RejectHostKey cleared only the pending key, so pressing it left HasHostKeyMismatch
set and this opaque panel up over whatever the user navigated to next — including the host editor
the paragraph above sends them to. And the shell no longer moves to Hosts to ask the question, so
even fixed it does not land there: it lowers this panel and reveals the screen that was already
underneath. "BACK" is what that is. See VaultViewModel.RejectHostKey.
-->
<Button Classes="secondary" Content="BACK" Margin="0,24,0,0"
Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</ScrollViewer>
</Border>
</Panel>
</UserControl>