Public Access
Actually keep the phone's sessions alive when the app is backgrounded
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.
This commit is contained in:
@@ -90,32 +90,61 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
|
|
||||||
shell.DataContext = viewModel;
|
shell.DataContext = viewModel;
|
||||||
|
|
||||||
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
|
||||||
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
// does and why.
|
||||||
//
|
ComposeKeepAlive(workspace, viewModel);
|
||||||
// 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();
|
|
||||||
|
|
||||||
return shell;
|
return shell;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
|
||||||
|
/// a vault lock — true on a platform that stops backgrounded processes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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. <c>holdsFileSession</c> 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
|
||||||
|
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
|
||||||
|
/// cipher, which a bucket never has — that answers whether one is open.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>keepAlive</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writing to this phone's clipboard.
|
/// Writing to this phone's clipboard.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using global::Android.OS;
|
|||||||
namespace DodoSSH.Client.Android.Platform;
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
|
|||||||
private const string ChannelId = "dodossh.sessions";
|
private const string ChannelId = "dodossh.sessions";
|
||||||
private const int NotificationId = 1;
|
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>
|
/// <remarks>
|
||||||
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
|
/// 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.
|
/// 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)
|
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
|
// 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
|
// 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;
|
return StartCommandResult.NotSticky;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <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
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
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))
|
if (OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||||
{
|
{
|
||||||
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
|
|||||||
}
|
}
|
||||||
|
|
||||||
var reopen = PendingIntent.GetActivity(
|
var reopen = PendingIntent.GetActivity(
|
||||||
this,
|
context,
|
||||||
0,
|
0,
|
||||||
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||||
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
||||||
|
|
||||||
return new Notification.Builder(this, ChannelId)
|
return new Notification.Builder(context, ChannelId)
|
||||||
.SetContentTitle("DodoSSH")
|
.SetContentTitle("DodoSSH")
|
||||||
.SetContentText(summary)
|
.SetContentText(summary)
|
||||||
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
||||||
@@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service
|
|||||||
.Build();
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts or stops the service to match what is actually running.</summary>
|
/// <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="liveSessions">Shells with a live channel behind them.</param>
|
||||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||||
public static void Reconcile(int liveSessions, int activeTransfers)
|
/// <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();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The summary says what is actually held, counted rather than generic — the same principle the
|
// 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.
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Summarise(int liveSessions, int activeTransfers)
|
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 parts = new List<string>(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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
if (liveSessions > 0)
|
||||||
{
|
{
|
||||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (holdsFileSession)
|
||||||
|
{
|
||||||
|
parts.Add("Files connected");
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTransfers > 0)
|
if (activeTransfers > 0)
|
||||||
{
|
{
|
||||||
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// a counter incremented on open and decremented on close would not.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
internal sealed class SessionKeepAlive : IDisposable
|
internal sealed class SessionKeepAlive : IDisposable
|
||||||
{
|
{
|
||||||
private readonly TerminalWorkspace workspace;
|
private readonly TerminalWorkspace workspace;
|
||||||
private readonly Func<int> activeTransfers;
|
private readonly Func<int> activeTransfers;
|
||||||
|
private readonly Func<bool> holdsFileSession;
|
||||||
|
|
||||||
/// <param name="workspace">The live shells.</param>
|
/// <param name="workspace">The live shells.</param>
|
||||||
/// <param name="activeTransfers">
|
/// <param name="activeTransfers">
|
||||||
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
|
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
|
||||||
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
|
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
|
||||||
/// will arrive through rather than a dependency taken before there is anything to depend on.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers)
|
/// <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.workspace = workspace;
|
||||||
this.activeTransfers = activeTransfers;
|
this.activeTransfers = activeTransfers;
|
||||||
|
this.holdsFileSession = holdsFileSession;
|
||||||
|
|
||||||
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
|
// 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.
|
// a binder call and needs no particular thread. Nothing here touches the interface.
|
||||||
@@ -41,13 +53,14 @@ internal sealed class SessionKeepAlive : IDisposable
|
|||||||
|
|
||||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
|
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
||||||
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
|
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
||||||
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
|
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
|
||||||
/// stopped one, and Android treats both as no-ops.
|
/// 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>
|
/// </remarks>
|
||||||
public void Refresh() =>
|
public void Refresh() =>
|
||||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
|
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
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
|
// 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.
|
// 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();
|
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||||
|
|||||||
@@ -12,7 +12,12 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. -->
|
<!--
|
||||||
|
The service's persistent notification. Requested on API 33+ from SessionForegroundService.Reconcile,
|
||||||
|
the first time in this process there is actually something to show — not at launch, where the ask
|
||||||
|
would justify nothing on screen yet. Refusal is survivable: the service still starts and still holds
|
||||||
|
the process in the foreground either way, so a "no" costs the notification and nothing else.
|
||||||
|
-->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
||||||
|
|||||||
Reference in New Issue
Block a user