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