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,134 @@
<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.KeychainScreen"
x:DataType="vm:VaultViewModel"
Background="{StaticResource Canvas}">
<!--
Design 04 — KEYCHAIN: everything in the vault that is not a host.
Two things from the design are structural rather than decorative and both are kept.
The category chips carry counts, because "SSH KEYS 8" answers a question the word alone does not.
And ◆ the delete confirmation replaces the row in place rather than stacking a dialogue over it — the
design is explicit about that, and on a phone it matters more than on the desktop: a modal over a list
hides which row it is talking about, which is exactly the information the confirmation exists to give.
-->
<Grid RowDefinitions="Auto,Auto,*">
<!-- ============ categories ============ -->
<ScrollViewer Grid.Row="0" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
Margin="0,12,0,4">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0">
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsAll, Mode=OneWay}"
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.All}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="ALL" />
<TextBlock Text="{Binding TotalItemCount}" />
</StackPanel>
</RadioButton>
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsKeys, Mode=OneWay}"
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Keys}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="SSH KEYS" />
<TextBlock Text="{Binding Keys.Count}" />
</StackPanel>
</RadioButton>
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsCredentials, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Credentials}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="PASSWORDS" />
<TextBlock Text="{Binding Credentials.Count}" />
</StackPanel>
</RadioButton>
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsBuckets, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Buckets}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="BUCKETS" />
<TextBlock Text="{Binding ObjectStores.Count}" />
</StackPanel>
</RadioButton>
</StackPanel>
</ScrollViewer>
<!-- ============ ◆ the counted confirmation ============ -->
<!--
In place, above the list it refers to. The text comes from the view model, which counts what breaks —
"three hosts authenticate with this key and will refuse to connect" — and the count is the whole
difference between a sentence somebody reads and one they click past.
-->
<Border Grid.Row="1" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="5">
<TextBlock Classes="mono" FontSize="12" Text="{Binding PendingDeletion.Question}" TextWrapping="Wrap" />
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
Text="{Binding PendingDeletion.Consequence}" />
<!-- The line worth reading twice: what depends on the item, counted. Absent when nothing does. -->
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
IsVisible="{Binding PendingDeletion.HasUsage}"
Text="{Binding PendingDeletion.Usage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
<Button Grid.Column="0" Classes="danger" Content="DELETE"
Command="{Binding ConfirmDeleteCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
Command="{Binding CancelDeleteCommand}" />
</Grid>
</StackPanel>
</Border>
<!-- ============ the items ============ -->
<Panel Grid.Row="2">
<!-- Written per section by the view model, and it says what the section is for. -->
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
VerticalAlignment="Top" Text="{Binding EmptySectionMessage}" />
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVaultItems}">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="56" Margin="16,6">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13" FontWeight="Medium" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<Border BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="3"
Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Text="{Binding Type}" />
</Border>
<Border IsVisible="{Binding HasBadge}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" CornerRadius="3" Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
Text="{Binding Badge}" />
</Border>
</StackPanel>
<!-- What is known *about* an item, never the secret. -->
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Panel>
</Grid>
</UserControl>