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,53 @@
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
using global::Android.App;
using global::Android.Runtime;
using Avalonia;
using Avalonia.Android;
using DodoSSH.Client.Android.Platform;
namespace DodoSSH.Client.Android;
/// <summary>
/// The Android <c>Application</c> object, and where Avalonia is configured.
/// </summary>
/// <remarks>
/// <para>
/// Avalonia 12 moved the <see cref="AppBuilder"/> hooks here from the activity, which is a better fit than
/// it first looks: an activity is destroyed and recreated, and the application object is not. The desktop
/// head's equivalent is <c>Program.BuildAvaloniaApp</c>.
/// </para>
/// <para>
/// Any Avalonia 11 sample will show this on <c>AvaloniaMainActivity&lt;App&gt;</c> instead. That type is
/// still here and is still what the launcher activity derives from — it simply no longer takes the
/// application as a type argument.
/// </para>
/// </remarks>
[Application(Label = "DodoSSH")]
public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoSshApp>
{
/// <remarks>
/// The JNI constructor, and the only one Android calls. It exists to hand the managed object its Java
/// peer; there is nothing to do in it and nothing may be done in it, because the application context is
/// not usable until <see cref="OnCreate"/>.
/// </remarks>
public DodoSshAndroidApplication(nint javaReference, JniHandleOwnership transfer)
: base(javaReference, transfer)
{
}
/// <inheritdoc />
public override void OnCreate()
{
// Before Avalonia, and from the application rather than the activity: this is the context whose
// lifetime matches the composition root's, and reading filesDir is the first thing startup does.
PhoneEnvironment.Attach(this);
base.OnCreate();
}
/// <inheritdoc />
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
base.CustomizeAppBuilder(builder).WithInterFont();
}