Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s

All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
This commit is contained in:
2026-07-31 21:43:11 +02:00
parent 81e7e6d939
commit 7a3a521c59
51 changed files with 2144 additions and 134 deletions
@@ -0,0 +1,173 @@
<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.HostsScreen"
x:DataType="vm:VaultViewModel"
Background="{StaticResource Canvas}">
<!--
Design 02 — HOSTS, the phone's home.
The desktop's sidebar and its right-hand connect column cannot both be on screen at 360dp, so they are
the same column here: the list fills the screen, and choosing a host raises the connect bar over the
bottom of it. That is the one structural change from the design, and it is why the bar names the host
it will connect to — on the desktop the selection is visible beside the button, and here it is not.
Groups are rows in the same list rather than a nesting container, which is what SidebarRows already
hands both heads. The desktop shows group headings only when groups exist and so does this, for free.
-->
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- ============ filter ============ -->
<!--
The design's "quick connect · search hosts" bar. On the desktop this is Ctrl+K over a palette; on a
phone the list is already the palette, so it filters in place rather than opening a second surface.
-->
<Border Grid.Row="0" Margin="16,12,16,4" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="6">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="&gt;" Classes="detail" Margin="12,0,0,0"
VerticalAlignment="Center" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="search hosts"
Height="44" Padding="9,0" VerticalContentAlignment="Center"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="11.5" />
</Grid>
</Border>
<!-- ============ sync + conflicts ============ -->
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="6">
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
<!--
◆ The conflict log. The merge is only allowed to pick a winner because this exists, so it is a
banner the user has to dismiss rather than a screen they might never open.
-->
<Border IsVisible="{Binding HasConflicts}" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
<StackPanel Spacing="8">
<ItemsControl ItemsSource="{Binding Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Classes="detail" Foreground="{StaticResource WarnText}" TextWrapping="Wrap"
Text="{Binding Summary}" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="secondary" Height="40" Content="DISMISS ALL" FontSize="10"
Command="{Binding AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
<Border IsVisible="{Binding HasUnreadableItems}" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
<TextBlock Classes="detail" Foreground="{StaticResource DangerText}" TextWrapping="Wrap"
Text="{Binding UnreadableSummary}" />
</Border>
</StackPanel>
<!-- ============ the list ============ -->
<ListBox Grid.Row="2" Margin="0,8,0,0"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
</ListBox.Styles>
<ListBox.DataTemplates>
<!--
A group heading. SidebarGroupHeader, not HostGroupRowViewModel — the latter is a row in the
groups editor, and the two are deliberately different shapes: this one carries the count *after*
the filter, which is what a heading over a filtered list has to say.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="16,12,16,6">
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}" Margin="7,0,0,0" />
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9" />
</Grid>
</DataTemplate>
<!-- A host. -->
<DataTemplate DataType="vm:HostRowViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="54" Margin="16,6">
<!-- Green means a terminal is open on this host right now. See the .dot style. -->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
TextTrimming="CharacterEllipsis" />
<Border IsVisible="{Binding HasVaultBadge}" BorderBrush="{StaticResource BorderFaint}"
BorderThickness="1" CornerRadius="3" Padding="4,0" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8.5" Text="{Binding VaultBadge}" />
</Border>
<Border IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="3"
Padding="4,0" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
Text="{Binding Badge}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="detail" Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" Text="·" />
<TextBlock Classes="detail" Text="{Binding Authentication}" />
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
<!-- ============ connect ============ -->
<!--
Raised over the list when a host is chosen, because the desktop's right-hand column has nowhere to go
at this width. It names the host: the selection that is obvious on a wide window is not obvious here.
-->
<Border Grid.Row="3" IsVisible="{Binding SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="16,12">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="CONNECT TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedHost.Label}" />
</StackPanel>
<!--
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
-->
<TextBox Classes="field" Height="44" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PasswordChar="•" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !SelectedHostAsksForAPassword}"
Text="{Binding SelectedHostAuthenticationNote}" />
<Button Classes="primary" Content="CONNECT" Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
</Border>
</Grid>
</UserControl>