Public Access
The foreground service existed, and four defects in its wiring meant it mostly did not run. A shell opening was never announced to it — only the ending was — so the service never came up for a shell at all. An idle connected Files session counted as nothing. Every refresh restarted the service, which Android 12+ answers with a crash the moment the app is backgrounded — a transfer finishing in the pocket took the remaining connections with it. And POST_NOTIFICATIONS was declared but never requested, so on Android 13+ the receipt was silently invisible. Updates while backgrounded now go through the notification manager; a foregrounded refresh still prefers a real start, so a stop still in flight cannot leave an orphan receipt over an unprotected process.
280 lines
14 KiB
C#
280 lines
14 KiB
C#
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, a transfer, or a connected Files session 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;
|
|
|
|
// API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
|
|
// back — 1 is fine because this head never implements that callback at all, see
|
|
// RequestNotificationPermission's own remark for why a result is not worth listening for.
|
|
private const int NotificationPermissionRequestCode = 1;
|
|
|
|
/// <summary>
|
|
/// Whether <see cref="OnStartCommand"/> has run for this process without a matching
|
|
/// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Volatile because <see cref="Reconcile"/> can run on whatever thread called
|
|
/// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
|
|
/// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
|
|
/// read here is the difference between updating a notification in place and calling
|
|
/// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
|
|
/// </remarks>
|
|
private static volatile bool running;
|
|
|
|
private static bool notificationPermissionRequested;
|
|
|
|
/// <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)
|
|
{
|
|
running = true;
|
|
|
|
StartForeground(NotificationId, BuildNotification(this, 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
|
|
/// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
|
|
/// clearing the flag directly, so this override is the one place that actually knows the service has
|
|
/// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
|
|
/// </remarks>
|
|
public override void OnDestroy()
|
|
{
|
|
running = false;
|
|
|
|
base.OnDestroy();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
|
|
/// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
|
|
/// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
|
|
/// — because posting to an already-running notification never touches the service's own lifecycle.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static Notification BuildNotification(Context context, string summary)
|
|
{
|
|
var manager = (NotificationManager)context.GetSystemService(Context.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(
|
|
context,
|
|
0,
|
|
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
|
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
|
|
|
return new Notification.Builder(context, ChannelId)
|
|
.SetContentTitle("DodoSSH")
|
|
.SetContentText(summary)
|
|
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
|
.SetContentIntent(reopen)
|
|
.SetOngoing(true)!
|
|
.Build();
|
|
}
|
|
|
|
/// <summary>Starts, stops, or refreshes the service's notification 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>
|
|
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
|
|
/// <remarks>
|
|
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
|
|
/// always. Something live and the service not yet running starts it. Something live and the service
|
|
/// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
|
|
/// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
|
|
/// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
|
|
/// remaining connections with it. That case — running, and the app backgrounded — now only posts a
|
|
/// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
|
|
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
|
|
/// start even over a service that looks like it is running; the inline remark below is why.
|
|
/// </remarks>
|
|
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
|
|
{
|
|
var context = PhoneEnvironment.Require();
|
|
|
|
if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
|
|
{
|
|
context.StopService(new Intent(context, typeof(SessionForegroundService)));
|
|
|
|
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.
|
|
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
|
|
|
|
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
|
|
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
|
|
// by a quick reconnect can land here while the StopService just issued is still in flight, and
|
|
// posting to that dying service's notification would leave an orphan receipt over an unprotected
|
|
// process — restarting instead makes the flag's small lag harmless. A start on a service that
|
|
// really is running only re-delivers OnStartCommand, whose StartForeground updates the same
|
|
// notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
|
|
if (running && PhoneEnvironment.CurrentActivity is null)
|
|
{
|
|
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
|
|
manager.Notify(NotificationId, BuildNotification(context, summary));
|
|
|
|
return;
|
|
}
|
|
|
|
RequestNotificationPermission(context);
|
|
Start(context, summary);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
|
|
/// otherwise crowd the three-way branch above it.
|
|
/// </remarks>
|
|
private static void Start(Context context, string summary)
|
|
{
|
|
var intent = new Intent(context, typeof(SessionForegroundService));
|
|
intent.PutExtra("summary", summary);
|
|
|
|
try
|
|
{
|
|
context.StartForegroundService(intent);
|
|
}
|
|
catch (Java.Lang.IllegalStateException)
|
|
{
|
|
// ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
|
|
// means a start-worthy transition — a shell opening, a Files connection completing, the first
|
|
// transfer landing in an empty queue — happened while the app was backgrounded, which is
|
|
// precisely when Android refuses a new foreground start. There is no retry that helps: by the
|
|
// time this catch runs, the moment such a start would have been allowed has already passed.
|
|
// Swallowing it is the honest choice and not just the available one — letting the exception
|
|
// propagate would crash the process and drop the very shells and transfers this service exists
|
|
// to keep alive. A process that keeps running unprotected outlives one that does not run at all.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks for the receipt notification's own permission, the first time this process actually has
|
|
/// something to show rather than at launch.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
|
|
/// platform limit, since Android already refuses to show the dialogue twice, but because a second call
|
|
/// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
|
|
/// read back: there is nothing this class would do differently for a grant versus a refusal, so a
|
|
/// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
|
|
/// notification stays invisible — and what it does not cost is the point: the service still starts,
|
|
/// still holds the process in the foreground, and the shells and transfers it protects are exactly as
|
|
/// safe as if the user had said yes. See the manifest's own comment on this permission.
|
|
/// </remarks>
|
|
private static void RequestNotificationPermission(Context context)
|
|
{
|
|
// Isolated as its own guard clause rather than folded into the compound condition below: the
|
|
// platform-compatibility analyzer only recognises a version check as guarding what follows when it
|
|
// is the sole condition of its own early return, and PostNotifications is annotated API 33+.
|
|
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Read into a local rather than referenced again inside the lambda below: the guard clause above
|
|
// covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
|
|
// lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
|
|
// the already-validated string sidesteps the false positive without weakening the actual check.
|
|
var postNotifications = global::Android.Manifest.Permission.PostNotifications;
|
|
|
|
if (notificationPermissionRequested
|
|
|| PhoneEnvironment.CurrentActivity is not { } activity
|
|
|| context.CheckSelfPermission(postNotifications) == Permission.Granted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
notificationPermissionRequested = true;
|
|
|
|
activity.RunOnUiThread(() =>
|
|
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
|
|
}
|
|
|
|
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
|
|
{
|
|
var parts = new List<string>(3);
|
|
|
|
if (liveSessions > 0)
|
|
{
|
|
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
|
}
|
|
|
|
if (holdsFileSession)
|
|
{
|
|
parts.Add("Files connected");
|
|
}
|
|
|
|
if (activeTransfers > 0)
|
|
{
|
|
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
|
}
|
|
|
|
return string.Join(" · ", parts);
|
|
}
|
|
}
|