using global::Android.App; using global::Android.Content; using global::Android.Content.PM; using global::Android.OS; namespace DodoSSH.Client.Android.Platform; /// /// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live. /// /// /// /// 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. /// /// /// Why this exists at all is worth stating plainly. TerminalWorkspace'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. /// /// /// It holds no state and owns nothing. 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 /// . /// /// [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; /// /// 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. /// 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; } /// /// /// 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 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(); } /// 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. /// 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(); 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); } /// /// 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 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"); } return string.Join(" · ", parts); } }