Public Access
The phone's notification kept saying '1 shell connected' after the shell was gone, and both close routes were at fault. A shell exiting on its own raised SessionEnded from inside its run's finally block — where the run task is by definition not yet complete, so the LiveSessionCount the keep-alive reads still counted the dead shell, and nothing fired later to correct it. A tab closed by hand announced nothing at all, by a recorded decision that assumed every subscriber was the closer; the keep-alive is not, and a close it never heard about left the notification claiming a shell over nothing. The end is now announced from a continuation after the run completes, and CloseSessionAsync announces after its own drain — every subscriber was already a reconcile-to-reality handler, so the echo the old remark feared costs nothing. Shutdown stays silent: it is dismantling the subscribers along with the sessions.
80 lines
4.2 KiB
C#
80 lines
4.2 KiB
C#
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.Android.Platform;
|
|
|
|
/// <summary>
|
|
/// Keeps <see cref="SessionForegroundService"/> in step with what is actually running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The service is started and stopped from one place, and that place is a count rather than a lifecycle.
|
|
/// Anything else drifts: a service started when a shell opens and stopped when a tab closes would leave
|
|
/// the notification up after the last shell died on its own, and a phone showing "1 shell connected" over
|
|
/// nothing is the same dishonesty the unlock screen's shell count exists to avoid.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="TerminalWorkspace.LiveSessionCount"/> is deliberately the source of truth rather than a
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Three facts feed <see cref="SessionForegroundService.Reconcile"/>, 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. <c>holdsFileSession</c> below is that
|
|
/// gap closed, read the same way the other two facts are: asked, not cached.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class SessionKeepAlive : IDisposable
|
|
{
|
|
private readonly TerminalWorkspace workspace;
|
|
private readonly Func<int> activeTransfers;
|
|
private readonly Func<bool> holdsFileSession;
|
|
|
|
/// <param name="workspace">The live shells.</param>
|
|
/// <param name="activeTransfers">
|
|
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
|
|
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
|
|
/// </param>
|
|
/// <param name="holdsFileSession">
|
|
/// 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 <c>TransfersViewModel.HasLiveFileSession</c>.
|
|
/// </param>
|
|
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
|
|
{
|
|
this.workspace = workspace;
|
|
this.activeTransfers = activeTransfers;
|
|
this.holdsFileSession = holdsFileSession;
|
|
|
|
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
|
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
|
|
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
|
|
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
|
|
// from it honest — the event's own remark carries the stuck notification that taught us both.
|
|
workspace.SessionEnded += OnSessionEnded;
|
|
}
|
|
|
|
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
|
/// <remarks>
|
|
/// 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 <c>startForegroundService</c> — or,
|
|
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
|
|
/// and Android treats all of those as no-ops.
|
|
/// </remarks>
|
|
public void Refresh() =>
|
|
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
workspace.SessionEnded -= OnSessionEnded;
|
|
|
|
// 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, false);
|
|
}
|
|
|
|
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
|
}
|