Give DodoSSH a phone, and a shared shell for both heads to drive

The Android head from docs/android-port.md, taken as far as its step 6.

Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and
libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android
despite shipping no Android build, and the local cache opens. Two findings the audit
could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which
settles the open "which Android versions" question at targetSdk 36; and Android has
blocked cleartext HTTP since API 28, so the terminal renderer needs a network security
config scoped to 127.0.0.1 or the WebView loads nothing.

DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal
renderer files and the palette moved there so both heads drive one state machine and draw
from one set of tokens. The desktop head is otherwise untouched and its 144 tests still
pass.

The platform pieces behind interfaces that already existed: the profile directory from
filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and
a foreground service so a shell outliving a vault lock stays true on a platform that
stops backgrounded processes.

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
This commit is contained in:
2026-07-31 20:58:48 +02:00
parent 03e902a2d2
commit fe9d7fc289
65 changed files with 3034 additions and 103 deletions
@@ -0,0 +1,128 @@
<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.LockedScreen"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
Design 01 — LOCKED, and one of the five states docs/android-port.md marks as most likely to be lost at
360dp. Two things on it are load-bearing and neither is decoration:
the count of shells still connected, and the paragraph under it. Locking describes the keychain and not
this phone's access to the hosts, and the desktop's README says so at length; on a phone, where the
lock screen is most of what a user sees, saying it here is the only place it fits. The block is absent
rather than empty when nothing is connected — a card reading "0 shells" would be noise on every launch.
Everything is one column with generous vertical slack above and below the controls, because the
software keyboard takes roughly half the screen the moment the passphrase box is focused. The slack is
what it eats.
-->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid RowDefinitions="*,Auto,Auto" Margin="24,0">
<!-- Identity. The chip names the account so a phone with two profiles is not a guess. -->
<StackPanel Grid.Row="0" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="10" Margin="0,48,0,36">
<Border Width="44" Height="44" BorderBrush="{StaticResource Accent}" BorderThickness="1"
HorizontalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource Accent}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Text="DodoSSH" Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}"
FontSize="15" FontWeight="SemiBold" HorizontalAlignment="Center" />
<Border BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="3"
Padding="9,3" HorizontalAlignment="Center"
IsVisible="{Binding AccountName, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource TextDim}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Medium" />
</Border>
</StackPanel>
<StackPanel Grid.Row="1">
<!--
Enter unlocks, which matters more here than on the desktop: the software keyboard's action key is
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
phone client called slow.
-->
<TextBox Text="{Binding Passphrase}" PasswordChar="•" Watermark="vault passphrase"
Height="48" Padding="14,0" VerticalContentAlignment="Center"
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"
FontFamily="{StaticResource MonoFont}" FontSize="12"
IsEnabled="{Binding !IsBusy}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Content="UNLOCK" Command="{Binding UnlockCommand}" IsEnabled="{Binding !IsBusy}"
Height="48" Margin="0,10,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Background="{StaticResource Accent}" Foreground="{StaticResource Canvas}"
CornerRadius="6" FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="SemiBold" />
<!--
Present only when this phone actually holds a device key. The design draws it unconditionally,
but offering a fingerprint that cannot open anything is worse than not offering one — see
AndroidDeviceKeyStore for the three ordinary ways it stops being available.
-->
<Button Command="{Binding UnlockWithDeviceCommand}" IsVisible="{Binding CanUnlockWithDevice}"
IsEnabled="{Binding !IsBusy}"
Height="48" Margin="0,8,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Background="Transparent" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
CornerRadius="6" Foreground="{StaticResource Text}">
<StackPanel Orientation="Horizontal" Spacing="9">
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
VerticalAlignment="Center" />
<TextBlock Text="UNLOCK WITH FINGERPRINT" FontFamily="{StaticResource MonoFont}"
FontSize="11" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Button>
<TextBlock Text="works with no network — the keychain decrypts on this phone"
Foreground="{StaticResource TextFaint}" FontFamily="{StaticResource MonoFont}"
FontSize="10" TextAlignment="Center" TextWrapping="Wrap" Margin="0,14,0,0" />
<TextBlock Text="{Binding StatusMessage}" Foreground="{StaticResource TextDim}"
FontFamily="{StaticResource MonoFont}" FontSize="10" TextAlignment="Center"
TextWrapping="Wrap" Margin="0,8,0,0" />
<!--
◆ The disclosure. Absent when there is nothing to disclose; never a card reading zero.
-->
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Warn}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="SemiBold"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Foreground="{StaticResource WarnText}" FontFamily="{StaticResource MonoFont}"
FontSize="10" TextWrapping="Wrap"
Text="Locked describes the keychain — not this phone's access to the hosts. Open sessions stay alive behind this screen." />
</StackPanel>
</Border>
</StackPanel>
<!--
The only answer to a forgotten passphrase, and it is deliberately the last thing on the screen and
the only red one. Nothing can recover a passphrase; this empties the phone and starts again.
-->
<Button Grid.Row="2" Command="{Binding SignOutCommand}"
Margin="0,28,0,20" Padding="0,14" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center" Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Danger}" FontFamily="{StaticResource MonoFont}"
FontSize="10.5" FontWeight="Medium"
Content="RESET THIS PHONE — forgot passphrase" />
</Grid>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 01 — the unlock screen.</summary>
internal sealed partial class LockedScreen : UserControl
{
public LockedScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -0,0 +1,38 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.PendingScreen"
x:DataType="views:PendingScreen"
Background="{StaticResource Canvas}">
<!--
The phone's counterpart of the desktop head's NotBuiltScreen, and it exists for the same reason: a
screen that is not built says so, in its own words, rather than being dropped from the shell or filled
with plausible-looking data. See README — nothing here is rendered with invented data.
Two properties rather than a shared string, because the copy is written per state. "No items" would be
the exact failure this control is here to avoid.
-->
<ScrollViewer>
<StackPanel VerticalAlignment="Center" Margin="24,48" Spacing="12">
<Border Width="44" Height="44" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
HorizontalAlignment="Left">
<TextBlock Text="&gt;_" Foreground="{StaticResource TextFaint}" FontFamily="{StaticResource MonoFont}"
FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Text="{Binding Heading}" Foreground="{StaticResource Text}"
FontFamily="{StaticResource MonoFont}" FontSize="14" FontWeight="SemiBold"
TextWrapping="Wrap" />
<TextBlock Text="{Binding Detail}" Foreground="{StaticResource TextDim}"
FontFamily="{StaticResource MonoFont}" FontSize="11" LineHeight="19"
TextWrapping="Wrap" />
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,43 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// A state this head has not built, saying so in its own words.
/// </summary>
/// <remarks>
/// Styled properties rather than a view model: there is no state behind this control, and giving it one
/// would make it look like a screen that might one day have data.
/// </remarks>
internal sealed partial class PendingScreen : UserControl
{
public static readonly StyledProperty<string> HeadingProperty =
AvaloniaProperty.Register<PendingScreen, string>(nameof(Heading), string.Empty);
public static readonly StyledProperty<string> DetailProperty =
AvaloniaProperty.Register<PendingScreen, string>(nameof(Detail), string.Empty);
public PendingScreen()
{
AvaloniaXamlLoader.Load(this);
// Its own data context, so the two properties can be bound in XAML like anything else. Safe here
// and only here: this control deliberately shows nothing from the shell, so there is no inherited
// context worth keeping.
DataContext = this;
}
public string Heading
{
get => GetValue(HeadingProperty);
set => SetValue(HeadingProperty, value);
}
public string Detail
{
get => GetValue(DetailProperty);
set => SetValue(DetailProperty, value);
}
}
@@ -0,0 +1,58 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.PhoneShell"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
The phone's single view, and the counterpart of the desktop head's MainWindow — except that this one
has no window, no nav rail, no titlebar and no status bar. It switches on ShellState and nothing else.
The states are the same six the desktop has, and they are the same six for a good reason: they are the
shell's state machine, which both heads share. What differs is only what each one draws.
Panels rather than a template selector, matching the desktop head: each screen's visibility is one
binding, and the whole tree is laid out once. There is no WebView occlusion problem to design around
here, which is the one structural simplification the phone gets for free — see docs/android-port.md §9
for what is still unverified about that on this platform.
-->
<Panel>
<views:LockedScreen IsVisible="{Binding IsLocked}" DataContext="{Binding}" />
<!--
The states this head has not built yet, named rather than hidden. The convention is the desktop
head's NotBuiltScreen and the reason is in README: nothing is rendered with invented data to fill a
screen, and a state that silently showed nothing would be indistinguishable from one that had
quietly broken.
Sign-in is the substantial one, and it is not merely unwritten — it needs a different redirect. See
docs/android-port.md §5: the loopback listener the desktop uses is the attack RFC 8252 §8.3 names on
a shared device, so this head needs an app link before it can honestly offer the flow at all.
-->
<views:PendingScreen IsVisible="{Binding IsStarting}"
Heading="OPENING THE KEYCHAIN"
Detail="Reading this phone's local cache to find out whether it is enrolled." />
<views:PendingScreen IsVisible="{Binding IsNeedingServer}"
Heading="SIGN-IN IS NOT BUILT HERE YET"
Detail="This phone has no profile, and signing in needs a redirect this head does not have. The desktop client's loopback listener is deliberately not reused: on a shared device any other app can bind a loopback port, which is the attack RFC 8252 §8.3 names. An app link is the next piece of work. Enroll on the desktop client and this phone will unlock against the same vault." />
<views:PendingScreen IsVisible="{Binding IsNeedingEnrollment}"
Heading="ENROLLMENT IS NOT BUILT HERE YET"
Detail="This account has no vault key. Choosing a passphrase — and writing down the recovery code that follows it — happens on the desktop client for now." />
<views:PendingScreen IsVisible="{Binding IsShowingRecoveryCode}"
Heading="RECOVERY CODE"
Detail="This state is reachable only after enrollment, which this head does not do yet. It is the one screen a user must never be able to click past, so it is left unbuilt rather than approximated." />
<views:PendingScreen IsVisible="{Binding IsUnlocked}"
Heading="UNLOCKED"
Detail="The vault is open. The host list, the keychain and the terminal are the next tranche of screens; the view models behind all three are already here and already driven by the desktop head." />
</Panel>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>The phone's single view. The desktop head's MainWindow, without the window.</summary>
internal sealed partial class PhoneShell : UserControl
{
public PhoneShell() => AvaloniaXamlLoader.Load(this);
}