using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
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();
// 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.
//
// Still zero transfers, and the reason moved rather than went away. v2 built the files screen, so
// this head can now browse a remote — but it cannot start a transfer, because both directions need
// the system document picker that scoped storage forces and that is not built (see FilesScreen).
// So the count is zero because the queue provably cannot have anything in it, not because nothing
// was wired. This is still the seam it arrives through: when the picker lands, this reads the
// queue and Refresh() gets called as transfers start and finish.
// 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.
//
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
// its own, which is the half that would otherwise leave a notification up over nothing.
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
keepAlive.Refresh();
return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) };
}
///
/// 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)
{
// 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);
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));
// 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) };
}