Public Access
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.
263 lines
13 KiB
C#
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) };
|
|
}
|