using DodoSSH.Client.Terminal; namespace DodoSSH.Client.Android.Platform; /// /// Keeps in step with what is actually running. /// /// /// /// 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. /// /// /// 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. /// /// /// 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 ownership of the /// transfer queue stays with TransfersViewModel — this class only ever asks it a question. /// /// /// 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 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; } /// Re-reads the counts and starts or stops the service to match. /// /// 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(), holdsFileSession()); /// 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(); }