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) }; }