Files
DodoSSH/src/DodoSSH.Client.Android/App.axaml.cs
T
jaap-jan 48ea5e22d5 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.
2026-08-09 10:14:17 +02:00

263 lines
13 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Android.Views;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Android;
/// <summary>
/// The Avalonia application, phone side.
/// </summary>
/// <remarks>
/// Named <c>DodoSshApp</c> for the same reason the desktop head's is, and then for a second reason on top
/// of it: a type called <c>App</c> in a namespace ending <c>.Android</c> is what makes every
/// <c>Android.App</c> in this assembly ambiguous. See the note at the top of MainActivity.
/// </remarks>
public sealed partial class DodoSshApp : Avalonia.Application
{
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
// ISingleViewApplicationLifetime, not IClassicDesktopStyleApplicationLifetime: a phone has one
// surface and no window to own. That difference is the whole reason the two heads cannot share a
// composition root, and very nearly the only one — everything either of them composes is the same.
if (ApplicationLifetime is ISingleViewApplicationLifetime single)
{
single.MainView = Compose();
}
base.OnFrameworkInitializationCompleted();
}
/// <remarks>
/// <para>
/// Composed by hand rather than through a container, matching the desktop head: the graph is a handful
/// of objects deep and an indirection to read through would buy nothing at this size. Read it beside
/// <c>DodoSSH.Client.App/App.axaml.cs</c> — the shape is deliberately identical, and the four
/// differences are the four things docs/android-port.md said would differ.
/// </para>
/// <para>
/// <b>Nothing here is disposed on a lifecycle hook</b>, and that is not an oversight either. Android
/// does not promise to call anything before killing a process, so a teardown path would be a comfort
/// rather than a guarantee. What actually protects the sessions is the foreground service; what
/// protects the vault keys is that they never leave memory this process owns.
/// </para>
/// </remarks>
private static PhoneShell Compose()
{
// Difference 1: the profile directory comes from the head. filesDir is per-app and non-roaming,
// which is what ClientPaths asks for and what no desktop platform guarantees.
var paths = PhoneEnvironment.Paths;
paths.EnsureCreated();
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
var knownHosts = new VaultKnownHostStore();
var connections = new SshNetConnectionFactory(knownHosts);
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
// Before anything can queue a transfer, which is the only moment at which emptying this is
// provably safe. What it clears is the copy a stopped upload leaves behind on purpose — kept so
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
DocumentStaging.Sweep();
// Built before the view model, because the clipboard is reached through it — see ClipboardWriter,
// which takes the surface and looks the TopLevel up on each call rather than now.
var shell = new PhoneShell();
var viewModel = ComposeShell(
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
shell.DataContext = viewModel;
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
// does and why.
ComposeKeepAlive(workspace, viewModel);
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>
/// Writing to this phone's clipboard.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>This head had none, and every control that wanted one said so out loud.</b> COPY PUBLIC KEY on
/// the keychain answered "This machine has no clipboard" on a device that plainly has one, because the
/// delegate was simply never wired here — the desktop passed one and the phone passed null. Android has
/// a clipboard and Avalonia surfaces it through the same <c>TopLevel</c> the desktop reaches, so there
/// was nothing platform-shaped about the gap.
/// </para>
/// <para>
/// Looked up per call rather than captured, exactly as the desktop's is: at composition there is no
/// <c>TopLevel</c> yet, because this is the method building the view it will be attached to. A machine
/// that somehow has none falls through silently and the view model decides what to say, which is what
/// keeps "no clipboard here" and "copied" different answers.
/// </para>
/// <para>
/// A delegate rather than an <c>IClipboard</c>, so nothing in the view models needs a visual and every
/// test that drives them stays window-free.
/// </para>
/// </remarks>
private static Func<string, Task> ClipboardWriter(Visual surface) =>
async text =>
{
if (TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard })
{
await clipboard.SetTextAsync(text).ConfigureAwait(false);
}
};
/// <remarks>
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
/// the platform graph, and below it is the shell every head shares.
/// </remarks>
private static MainWindowViewModel ComposeShell(
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
SshNetConnectionFactory connections,
Func<string, Task> copyToClipboard)
{
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
// key. A straight implementation of the interface the session layer has always taken.
var deviceKeys = new AndroidDeviceKeyStore(paths);
// Difference 6, and the newest: where newer builds come from. The same shape of question
// deviceKeys answers — a property of this installation, decided once, here — and the same answer
// when this copy was not installed by anything that can replace it. See ADR 0014.
//
// Its own HttpClient rather than the sync client's: this one talks to the project's forge and that
// one talks to the deployment, and the whole point of ADR 0011 rule 2 is that those are different
// parties. Sharing a handler would be one connection pool, one set of default headers and one
// place for a future change to leak a token from the second into the first. Never disposed, for
// the reason nothing else here is: an Avalonia Application has no disposal hook, and this lives as
// long as the process.
var updates = AndroidUpdateChannels.ForThisPhone(new HttpClient());
var browser = new AndroidBrowserLauncher();
var viewModel = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
// Difference 4: the system browser by intent, and the response by intent too rather than on a
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
.ConfigureAwait(false),
TimeProvider.System,
connections,
passphraseProfile: null,
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
// makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
// Difference 5: what this phone is called. The shell's default is Environment.MachineName, which
// answers localhost here — so without this the account's device list would show one localhost
// per phone, on the very screen a lost device is revoked from, and every log entry a phone wrote
// would name the same machine. See PhoneEnvironment.DeviceName.
deviceName: PhoneEnvironment.DeviceName,
copyToClipboard: copyToClipboard,
updates: updates);
// Started rather than awaited: framework initialisation must not block on a schema migration. The
// view model shows its own progress and handles its own failures.
_ = viewModel.StartAsync(CancellationToken.None);
return viewModel;
}
/// <summary>
/// Difference 4: where the authorization response comes back to.
/// </summary>
/// <remarks>
/// The only thing this head changes about signing in, and it changes it for a security reason rather
/// than a platform one. The desktop receives the response on a loopback <c>TcpListener</c>; on a phone
/// any other installed application can bind a loopback port and race for the authorization code, which
/// is the attack RFC 8252 §8.3 names. Android routes a registered redirect to this application
/// instead — see <see cref="AndroidRedirectCallback"/>, including what a private-use scheme does and
/// does not protect against.
/// </remarks>
private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
}