Public Access
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:
@@ -0,0 +1,134 @@
|
||||
using global::Android.App;
|
||||
using global::Android.Content;
|
||||
using global::Android.Content.PM;
|
||||
using global::Android.OS;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the process alive for as long as a shell or a transfer is live.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The decision recorded in docs/android-port.md: a persistent notification, for as long as there is
|
||||
/// something running that would be wrong to kill. It costs the user a notification and some battery, and it
|
||||
/// buys the behaviour the desktop client already promises and documents — that a shell outlives a vault
|
||||
/// lock, and that a transfer finishes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why this exists at all is worth stating plainly.</b> <c>TerminalWorkspace</c>'s guarantee is that
|
||||
/// locking the vault does not close your shells, because the remote host never consulted the vault and the
|
||||
/// credential was already spent. On a desktop that guarantee is free — the process keeps running. On
|
||||
/// Android nothing keeps a backgrounded process running, so without this the guarantee would quietly become
|
||||
/// desktop-only, and a phone would drop a shell the moment the user checked a message.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It holds no state and owns nothing.</b> The sessions live in the composition root, exactly as they do
|
||||
/// on the desktop; this only asks Android not to stop the process they are in. That is why starting and
|
||||
/// stopping it is a count of live things rather than a lifecycle of its own — see
|
||||
/// <see cref="SessionKeepAlive"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Service(
|
||||
Exported = false,
|
||||
|
||||
// Android 14 (API 34) refuses to start a foreground service whose type is not declared both here and
|
||||
// in the manifest's permission list. dataSync is the type that matches: an SSH session and a file
|
||||
// transfer are both the user's data moving to somewhere the user chose.
|
||||
ForegroundServiceType = ForegroundService.TypeDataSync)]
|
||||
internal sealed class SessionForegroundService : Service
|
||||
{
|
||||
private const string ChannelId = "dodossh.sessions";
|
||||
private const int NotificationId = 1;
|
||||
|
||||
/// <remarks>
|
||||
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
|
||||
/// wanted here: the point is that they outlive whatever the user does with the interface.
|
||||
/// </remarks>
|
||||
public override IBinder? OnBind(Intent? intent) => null;
|
||||
|
||||
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
||||
{
|
||||
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working"));
|
||||
|
||||
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
|
||||
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
|
||||
// longer exist, which is exactly the kind of dishonest state the unlock screen's shell count exists
|
||||
// to prevent.
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
|
||||
/// requires one, and because the user is entitled to know the app is holding connections open. Making
|
||||
/// it buzz would be a notification about nothing having happened.
|
||||
/// </remarks>
|
||||
private Notification BuildNotification(string summary)
|
||||
{
|
||||
var manager = (NotificationManager)GetSystemService(NotificationService)!;
|
||||
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||
{
|
||||
var channel = new NotificationChannel(ChannelId, "Live sessions", NotificationImportance.Low)
|
||||
{
|
||||
Description = "Shown while a shell or a transfer is open.",
|
||||
};
|
||||
|
||||
channel.SetShowBadge(false);
|
||||
manager.CreateNotificationChannel(channel);
|
||||
}
|
||||
|
||||
var reopen = PendingIntent.GetActivity(
|
||||
this,
|
||||
0,
|
||||
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
||||
|
||||
return new Notification.Builder(this, ChannelId)
|
||||
.SetContentTitle("DodoSSH")
|
||||
.SetContentText(summary)
|
||||
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
||||
.SetContentIntent(reopen)
|
||||
.SetOngoing(true)!
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>Starts or stops the service to match what is actually running.</summary>
|
||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||
public static void Reconcile(int liveSessions, int activeTransfers)
|
||||
{
|
||||
var context = PhoneEnvironment.Require();
|
||||
var intent = new Intent(context, typeof(SessionForegroundService));
|
||||
|
||||
if (liveSessions == 0 && activeTransfers == 0)
|
||||
{
|
||||
context.StopService(intent);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The summary says what is actually held, counted rather than generic — the same principle the
|
||||
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
|
||||
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers));
|
||||
|
||||
context.StartForegroundService(intent);
|
||||
}
|
||||
|
||||
private static string Summarise(int liveSessions, int activeTransfers)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
|
||||
if (liveSessions > 0)
|
||||
{
|
||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
||||
}
|
||||
|
||||
if (activeTransfers > 0)
|
||||
{
|
||||
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
||||
}
|
||||
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user