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,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>