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
+134
View File
@@ -0,0 +1,134 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Android.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Android;
/// <summary>
/// The Avalonia application, phone side.
/// </summary>
/// <remarks>
/// Named <c>DodoSshApp</c> for the same reason the desktop head's is, and then for a second reason on top
/// of it: a type called <c>App</c> in a namespace ending <c>.Android</c> is what makes every
/// <c>Android.App</c> in this assembly ambiguous. See the note at the top of MainActivity.
/// </remarks>
public sealed partial class DodoSshApp : Avalonia.Application
{
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
// ISingleViewApplicationLifetime, not IClassicDesktopStyleApplicationLifetime: a phone has one
// surface and no window to own. That difference is the whole reason the two heads cannot share a
// composition root, and very nearly the only one — everything either of them composes is the same.
if (ApplicationLifetime is ISingleViewApplicationLifetime single)
{
single.MainView = Compose();
}
base.OnFrameworkInitializationCompleted();
}
/// <remarks>
/// <para>
/// Composed by hand rather than through a container, matching the desktop head: the graph is a handful
/// of objects deep and an indirection to read through would buy nothing at this size. Read it beside
/// <c>DodoSSH.Client.App/App.axaml.cs</c> — the shape is deliberately identical, and the four
/// differences are the four things docs/android-port.md said would differ.
/// </para>
/// <para>
/// <b>Nothing here is disposed on a lifecycle hook</b>, and that is not an oversight either. Android
/// does not promise to call anything before killing a process, so a teardown path would be a comfort
/// rather than a guarantee. What actually protects the sessions is the foreground service; what
/// protects the vault keys is that they never leave memory this process owns.
/// </para>
/// </remarks>
private static PhoneShell Compose()
{
// Difference 1: the profile directory comes from the head. filesDir is per-app and non-roaming,
// which is what ClientPaths asks for and what no desktop platform guarantees.
var paths = PhoneEnvironment.Paths;
paths.EnsureCreated();
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
var knownHosts = new VaultKnownHostStore();
var connections = new SshNetConnectionFactory(knownHosts);
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
// Zero transfers for now: file transfer is out of this head's first scope by decision, and this is
// the seam it arrives through rather than a dependency taken before there is anything behind it.
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
//
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
// its own, which is the half that would otherwise leave a notification up over nothing.
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
// key. A straight implementation of the interface the session layer has always taken.
var deviceKeys = new AndroidDeviceKeyStore(paths);
var viewModel = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
SignInIsNotBuiltHere,
TimeProvider.System,
connections,
passphraseProfile: null);
// Started rather than awaited: framework initialisation must not block on a schema migration. The
// view model shows its own progress and handles its own failures.
_ = viewModel.StartAsync(CancellationToken.None);
keepAlive.Refresh();
return new PhoneShell { DataContext = viewModel };
}
/// <summary>
/// Difference 4, and the one that is a refusal rather than an implementation.
/// </summary>
/// <remarks>
/// <para>
/// Signing in needs a redirect this head has not got. The desktop client receives the authorization
/// response on a loopback <c>TcpListener</c> (RFC 8252 §7.3), and reusing that here would be a
/// security regression rather than a shortcut: on a shared device any other application can bind a
/// loopback port and race for the response, which is the attack §8.3 names and the reason app links
/// exist. <c>Process.Start</c> does not exist on this platform either.
/// </para>
/// <para>
/// So this throws rather than half-working, and the shell never reaches it: <c>NeedsServer</c> draws a
/// screen that says the same thing in the user's words. A phone enrolled from the desktop client
/// unlocks here perfectly well, because unlocking needs no network at all.
/// </para>
/// </remarks>
private static Task<IVaultServer> SignInIsNotBuiltHere(Uri serverUrl, CancellationToken cancellationToken) =>
throw new NotSupportedException(
"Signing in is not built on the Android head yet: it needs an app-link redirect rather than "
+ "the desktop client's loopback listener. See docs/android-port.md §5.");
}