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:
jaap-jan committed 2026-07-31 20:58:48 +02:00
1 parent 03e902a2d2
commit fe9d7fc289
65 files changed
+3034 -103

No files matched your search

@@ -0,0 +1,85 @@
using global::Android.Content;
using global::Android.OS;
using global::Android.Provider;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// The handful of facts about this phone that the platform-neutral layers need handed to them.
/// </summary>
/// <remarks>
/// <para>
/// Named <c>Phone</c> rather than <c>Android</c> because <c>Android.Runtime.AndroidEnvironment</c> already
/// exists and this type is not it.
/// </para>
/// <para>
/// Both members below are things docs/android-port.md called out as needing to come from the head rather
/// than be branched for inside the core: the profile directory, and a device name that is not
/// <c>Environment.MachineName</c>.
/// </para>
/// </remarks>
internal static class PhoneEnvironment
{
private static Context? context;
/// <summary>Captured once, from the launcher activity, before Avalonia starts.</summary>
/// <remarks>
/// The <em>application</em> context rather than the activity's. An activity is destroyed and recreated
/// on configuration changes this head does not declare as handled, and holding one in a static field is
/// the textbook Android leak; the application context lives as long as the process, which is exactly the
/// lifetime the composition root has.
/// </remarks>
public static void Attach(Context activity) =>
context = activity.ApplicationContext ?? activity;
/// <summary>Where this phone keeps its profile.</summary>
/// <remarks>
/// <c>filesDir</c> — per-app, non-roaming, not user-visible, and removed when the app is uninstalled.
/// <see cref="ClientPaths"/> asks for a local, non-roaming directory because two machines sharing one
/// cache file corrupts the outbox; on Android that is not merely satisfied but enforced by the platform,
/// since no other app can reach this path at all.
/// </remarks>
public static ClientPaths Paths =>
new(Require().FilesDir?.AbsolutePath
?? throw new InvalidOperationException("Android returned no filesDir for this application."));
/// <summary>
/// What this phone calls itself in connection and keychain log entries.
/// </summary>
/// <remarks>
/// <para>
/// <c>Environment.MachineName</c> returns <c>localhost</c> on Android, which would make every log entry
/// written from a phone indistinguishable from every other — the finding recorded in
/// docs/android-port.md §7.
/// </para>
/// <para>
/// <c>Settings.Global.DeviceName</c> is what the user themselves typed in Settings, so it is the name
/// they will recognise in a log written by a different device. It is null on phones that have never had
/// one set, and the fallback is the marketing model rather than the board name: a person reading a log
/// knows what a Pixel 8 is and does not know what <c>shiba</c> is.
/// </para>
/// </remarks>
public static string DeviceName
{
get
{
var chosen = Settings.Global.GetString(Require().ContentResolver, "device_name");
if (!string.IsNullOrWhiteSpace(chosen))
{
return chosen;
}
var model = Build.Model;
return string.IsNullOrWhiteSpace(model) ? "Android phone" : model;
}
}
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
public static Context Require() =>
context ?? throw new InvalidOperationException(
"PhoneEnvironment was read before MainActivity attached it.");
}