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;
///
/// The Avalonia application, phone side.
///
///
/// Named DodoSshApp for the same reason the desktop head's is, and then for a second reason on top
/// of it: a type called App in a namespace ending .Android is what makes every
/// Android.App in this assembly ambiguous. See the note at the top of MainActivity.
///
public sealed partial class DodoSshApp : Avalonia.Application
{
///
public override void Initialize() => AvaloniaXamlLoader.Load(this);
///
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();
}
///
///
/// 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
/// DodoSSH.Client.App/App.axaml.cs — the shape is deliberately identical, and the four
/// differences are the four things docs/android-port.md said would differ.
///
///
/// Nothing here is disposed on a lifecycle hook, 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.
///
///
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: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
//
// 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;
}
///
/// Writing to this phone's clipboard.
///
///
///
/// ◆ This head had none, and every control that wanted one said so out loud. 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 TopLevel the desktop reaches, so there
/// was nothing platform-shaped about the gap.
///
///
/// Looked up per call rather than captured, exactly as the desktop's is: at composition there is no
/// TopLevel 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.
///
///
/// A delegate rather than an IClipboard, so nothing in the view models needs a visual and every
/// test that drives them stays window-free.
///
///
private static Func ClipboardWriter(Visual surface) =>
async text =>
{
if (TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard })
{
await clipboard.SetTextAsync(text).ConfigureAwait(false);
}
};
///
/// Split from 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.
///
private static MainWindowViewModel ComposeShell(
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
SshNetConnectionFactory connections,
Func 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;
}
///
/// Difference 4: where the authorization response comes back to.
///
///
/// 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 TcpListener; 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 , including what a private-use scheme does and
/// does not protect against.
///
private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
}