From 48ea5e22d5477037eb66c851322b3dd0eac7cffe Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Sun, 9 Aug 2026 10:14:17 +0200 Subject: [PATCH] Actually keep the phone's sessions alive when the app is backgrounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/DodoSSH.Client.Android/App.axaml.cs | 73 +++++--- .../Platform/SessionForegroundService.cs | 177 ++++++++++++++++-- .../Platform/SessionKeepAlive.cs | 33 +++- .../Properties/AndroidManifest.xml | 7 +- 4 files changed, 241 insertions(+), 49 deletions(-) diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index 6f49eed..91f4ce3 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -90,32 +90,61 @@ public sealed partial class DodoSshApp : Avalonia.Application shell.DataContext = viewModel; - // 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. - // - // The transfer count is real now that the document picker gives this head a way to start one, and - // it is the half that matters most here: a shell survives backgrounding because somebody is looking - // at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is - // in a pocket. Queued counts as active, so putting five files in the queue and locking the phone - // moves five files. - // - // 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. - var keepAlive = new SessionKeepAlive( - workspace, - activeTransfers: () => viewModel.Transfers.ActiveTransfers); - - // The other end of the same wire: the workspace announces its own sessions ending, and the queue - // announces transfers appearing and finishing. Without this the notification would come up when an - // upload started and stay up after it finished, which is the failure this class exists to prevent. - viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh(); - - keepAlive.Refresh(); + // Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it + // does and why. + ComposeKeepAlive(workspace, viewModel); return shell; } + /// + /// Wires up the foreground service that makes TerminalWorkspace's promise — that a shell outlives + /// a vault lock — true on a platform that stops backgrounded processes. + /// + /// + /// + /// Split out of for length rather than for reuse; there is exactly one caller. + /// + /// + /// The transfer count is real now that the document picker gives this head a way to start one, and it + /// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it, + /// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a + /// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five + /// files. holdsFileSession covers the third case a count alone cannot: a host connected on the + /// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and + /// TransfersViewModel.HasLiveFileSession is the existing fact — IsConnected with a real + /// cipher, which a bucket never has — that answers whether one is open. + /// + /// + /// keepAlive is 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. + /// + /// + private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel) + { + var keepAlive = new SessionKeepAlive( + workspace, + activeTransfers: () => viewModel.Transfers.ActiveTransfers, + holdsFileSession: () => viewModel.Transfers.HasLiveFileSession); + + // The other end of the same wire: the workspace announces its own sessions ending, and the queue + // announces transfers appearing and finishing, and a Files session connecting or disconnecting. + // Without this the notification would come up when an upload started and stay up after it + // finished, which is the failure this class exists to prevent. + viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh(); + + // The half that was missing until now: a shell opening. SessionKeepAlive already heard the + // workspace announce a session ending, but nothing announced the opposite — a user who opened a + // shell and backgrounded the app had no foreground service at all, because the only wire in was the + // one for taking it down. TerminalSessionOpened is that other half, forwarded from + // VaultViewModel.SessionOpened, and without this line the service could never come up for a shell + // in the first place, which was precisely the promise this whole arrangement exists to keep. + viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh(); + + keepAlive.Refresh(); + } + /// /// Writing to this phone's clipboard. /// diff --git a/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs b/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs index 61e5f05..216efb1 100644 --- a/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs +++ b/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs @@ -6,7 +6,7 @@ using global::Android.OS; namespace DodoSSH.Client.Android.Platform; /// -/// Keeps the process alive for as long as a shell or a transfer is live. +/// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live. /// /// /// @@ -41,6 +41,26 @@ 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; + + /// + /// Whether has run for this process without a matching + /// since — i.e. whether Android currently considers this service foregrounded. + /// + /// + /// Volatile because can run on whatever thread called + /// , 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 + /// StartForegroundService on a service that is already running, which is what defect 3 was. + /// + private static volatile bool running; + + private static bool notificationPermissionRequested; + /// /// 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. @@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId) { - StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working")); + 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 @@ -58,14 +80,36 @@ internal sealed class SessionForegroundService : Service return StartCommandResult.NotSticky; } + /// /// + /// The other half of . Android calls this whether the service stopped itself or + /// was stopped from outside — 's down case calls StopService rather than + /// clearing the flag directly, so this override is the one place that actually knows the service has + /// gone, matching how is the one place that knows it has come up. + /// + public override void OnDestroy() + { + running = false; + + base.OnDestroy(); + } + + /// + /// /// 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. + /// + /// + /// Static, taking the it needs rather than reading this: the instance path + /// through passes the service itself, and the in-place update path through + /// has no service instance at all — only + /// — because posting to an already-running notification never touches the service's own lifecycle. + /// /// - private Notification BuildNotification(string summary) + private static Notification BuildNotification(Context context, string summary) { - var manager = (NotificationManager)GetSystemService(NotificationService)!; + var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!; if (OperatingSystem.IsAndroidVersionAtLeast(26)) { @@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service } var reopen = PendingIntent.GetActivity( - this, + context, 0, - new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop), + new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop), PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent); - return new Notification.Builder(this, ChannelId) + return new Notification.Builder(context, ChannelId) .SetContentTitle("DodoSSH") .SetContentText(summary) .SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo) @@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service .Build(); } - /// Starts or stops the service to match what is actually running. + /// Starts, stops, or refreshes the service's notification to match what is actually running. /// Shells with a live channel behind them. /// Transfers still moving bytes. - public static void Reconcile(int liveSessions, int activeTransfers) + /// Whether the Files screen holds a live, idle SFTP connection. + /// + /// 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 StartForegroundService a second time, which on + /// API 31+ throws ForegroundServiceStartNotAllowedException 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 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. + /// + public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession) { var context = PhoneEnvironment.Require(); - var intent = new Intent(context, typeof(SessionForegroundService)); - if (liveSessions == 0 && activeTransfers == 0) + if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession) { - context.StopService(intent); + 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. - intent.PutExtra("summary", Summarise(liveSessions, activeTransfers)); + var summary = Summarise(liveSessions, activeTransfers, holdsFileSession); - context.StartForegroundService(intent); + // 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); } - private static string Summarise(int liveSessions, int activeTransfers) + /// + /// Split out of for the try/catch alone, which needs its own remark and would + /// otherwise crowd the three-way branch above it. + /// + private static void Start(Context context, string summary) { - var parts = new List(2); + 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. + } + } + + /// + /// Asks for the receipt notification's own permission, the first time this process actually has + /// something to show rather than at launch. + /// + /// + /// At most once per process, via — not to work around a + /// platform limit, since Android already refuses to show the dialogue twice, but because a second call + /// to RequestPermissions 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. + /// + 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(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"); diff --git a/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs b/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs index c20a78a..1f97d3b 100644 --- a/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs +++ b/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs @@ -17,22 +17,34 @@ namespace DodoSSH.Client.Android.Platform; /// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which /// a counter incremented on open and decremented on close would not. /// +/// +/// Three facts feed , not one: live shells, moving +/// transfers, and an idle-but-connected Files session. The last of those used to be missing entirely — +/// a shell survives backgrounding because somebody opened it, but a Files connection with nothing moving +/// looked, to this class, exactly like nothing being open at all. holdsFileSession below is that +/// gap closed, read the same way the other two facts are: asked, not cached. +/// /// internal sealed class SessionKeepAlive : IDisposable { private readonly TerminalWorkspace workspace; private readonly Func activeTransfers; + private readonly Func holdsFileSession; /// The live shells. /// - /// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out - /// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it - /// will arrive through rather than a dependency taken before there is anything to depend on. + /// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the + /// transfer queue stays with TransfersViewModel — this class only ever asks it a question. /// - public SessionKeepAlive(TerminalWorkspace workspace, Func activeTransfers) + /// + /// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but- + /// connected case a transfer count alone would miss. See TransfersViewModel.HasLiveFileSession. + /// + public SessionKeepAlive(TerminalWorkspace workspace, Func activeTransfers, Func holdsFileSession) { this.workspace = workspace; this.activeTransfers = activeTransfers; + this.holdsFileSession = holdsFileSession; // Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is // a binder call and needs no particular thread. Nothing here touches the interface. @@ -41,13 +53,14 @@ internal sealed class SessionKeepAlive : IDisposable /// Re-reads the counts and starts or stops the service to match. /// - /// Called after anything that could change either count — opening a shell, closing a tab, a transfer - /// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is - /// either a redundant startForegroundService on a running service or a stopService on a - /// stopped one, and Android treats both as no-ops. + /// Called after anything that could change any of the three facts — opening a shell, closing a tab, a + /// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free: + /// reconciling to the state it is already in is either a redundant startForegroundService — or, + /// now, a redundant notification post — on a running service, or a stopService on a stopped one, + /// and Android treats all of those as no-ops. /// public void Refresh() => - SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers()); + SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession()); /// public void Dispose() @@ -56,7 +69,7 @@ internal sealed class SessionKeepAlive : IDisposable // The notification goes with the composition root. Leaving it up over a process that is shutting // down is how an SSH client acquires a reputation for a notification you cannot get rid of. - SessionForegroundService.Reconcile(0, 0); + SessionForegroundService.Reconcile(0, 0, false); } private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh(); diff --git a/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml b/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml index 70f4063..1aef838 100644 --- a/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml +++ b/src/DodoSSH.Client.Android/Properties/AndroidManifest.xml @@ -12,7 +12,12 @@ - +