using global::Android.Views; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Threading; using DodoSSH.Client.Android.Platform; using DodoSSH.Client.Shell.ViewModels; namespace DodoSSH.Client.Android.Views; /// The phone's single view. The desktop head's MainWindow, without the window. internal sealed partial class PhoneShell : UserControl { private MainWindowViewModel? shell; /// /// The open vault, while there is one, so that this control hears about the hosts screen's own state. /// /// /// ◆ A second subscription, and it is the price of the header being swappable. Two of the flags /// below are questions about the vault rather than about the shell — whether hosts are ticked, and /// whether the host editor is filling the screen — and the shell does not forward the vault's /// notifications. Kept in step from , because Vault is replaced on /// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive. /// private VaultViewModel? vault; /// /// Everything the phone draws, which is the element the software keyboard is kept off. /// /// /// Looked up rather than read off the field the name generator declares for x:Name, and /// TerminalScreen does the same for the same reason: that field is assigned by the generated /// InitializeComponent, and no view on this head calls it — they load their XAML directly. Using /// it compiles and is null at run time, which on this control means a crash before the first frame. /// private readonly Panel body; /// The software keyboard, while this control is attached. Null on a platform without one. private IInputPane? keyboard; /// /// Whether the lock screen currently showing is the one the application launched into. /// /// /// Set once, when the shell arrives, because this control is built once for the process. Cleared the /// moment the state leaves , and never set again — which is what stops a /// deliberate lock from being answered with an immediate request to unlock. See /// . /// private bool thisLockIsTheLaunch; private bool offeredDeviceUnlock; /// /// The width above which this surface is laid out like the desktop. /// /// /// /// 600, which is Android's own boundary between a compact window and a medium one, and is where a /// tablet, an unfolded foldable and a landscape phone land on the far side. It is measured in the units /// Avalonia lays out in, which are density-independent — so this is 600dp and not 600 physical pixels, /// and a 1080-pixel phone at 3× density is correctly on the narrow side of it. /// /// /// One number rather than a set of them. Android names three window classes and this head has two /// layouts, so a second breakpoint would be a third arrangement nothing has been designed for. /// /// private const double WideAt = 600; public PhoneShell() { AvaloniaXamlLoader.Load(this); body = this.FindControl("Body")!; // Subscribed once, for the life of the control, rather than in OnAttachedToVisualTree: the panel is // this control's own child and cannot outlive it, and re-subscribing on every attach is how a // handler ends up registered twice. body.SizeChanged += OnBodyResized; DataContextChanged += (_, _) => { if (shell is not null) { shell.PropertyChanged -= OnShellChanged; } shell = DataContext as MainWindowViewModel; if (shell is not null) { shell.PropertyChanged += OnShellChanged; ApplyScreenshotPolicy(shell.State); thisLockIsTheLaunch = true; TryOfferDeviceUnlock(); } FollowTheVault(); RefreshChrome(); }; } /// Moves this control's second subscription onto whichever vault is open now. /// /// Compared before being swapped, so that the ordinary case — a shell notification about something else /// entirely — costs one reference comparison rather than an unsubscribe and a resubscribe per property /// change on the shell. /// private void FollowTheVault() { if (ReferenceEquals(vault, shell?.Vault)) { return; } if (vault is not null) { vault.PropertyChanged -= OnVaultChanged; } vault = shell?.Vault; if (vault is not null) { vault.PropertyChanged += OnVaultChanged; } } private void OnVaultChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshChrome(); /// Whether this surface is wide enough to be laid out like the desktop. /// /// A property of the control rather than of the view model, because it is a fact about the surface and /// the view models are shared with a head where it is always true. The markup reads it through /// $parent[views:PhoneShell]. /// public static readonly StyledProperty IsWideProperty = AvaloniaProperty.Register(nameof(IsWide)); /// Whether the rail down the left edge is drawn. /// /// The wide surface's answer to and asks the same question about /// which screen is up, so the two move together — including over Connections with nothing running. See /// . /// public static readonly StyledProperty ShowsRailProperty = AvaloniaProperty.Register(nameof(ShowsRail)); /// Whether the three-entry bar across the bottom is drawn. /// /// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal /// surface drawing a page. See . /// public static readonly StyledProperty ShowsBottomBarProperty = AvaloniaProperty.Register(nameof(ShowsBottomBar)); /// Whether the header carrying the vault's name, the sync light and LOCK is drawn. public static readonly StyledProperty ShowsVaultHeaderProperty = AvaloniaProperty.Register(nameof(ShowsVaultHeader)); /// Whether the bar about the chosen hosts is drawn in the header's place. public static readonly StyledProperty ShowsHostSelectionBarProperty = AvaloniaProperty.Register(nameof(ShowsHostSelectionBar)); /// Whether the strip of open shells above the bottom bar is drawn. public static readonly StyledProperty ShowsShellStripProperty = AvaloniaProperty.Register(nameof(ShowsShellStrip)); /// public bool IsWide { get => GetValue(IsWideProperty); private set => SetValue(IsWideProperty, value); } /// public bool ShowsRail { get => GetValue(ShowsRailProperty); private set => SetValue(ShowsRailProperty, value); } /// public bool ShowsBottomBar { get => GetValue(ShowsBottomBarProperty); private set => SetValue(ShowsBottomBarProperty, value); } /// public bool ShowsVaultHeader { get => GetValue(ShowsVaultHeaderProperty); private set => SetValue(ShowsVaultHeaderProperty, value); } /// public bool ShowsHostSelectionBar { get => GetValue(ShowsHostSelectionBarProperty); private set => SetValue(ShowsHostSelectionBarProperty, value); } /// public bool ShowsShellStrip { get => GetValue(ShowsShellStripProperty); private set => SetValue(ShowsShellStripProperty, value); } /// /// Works out which chrome this surface should be wearing. /// /// /// /// ◆ Five flags computed here rather than five conditions in the markup, because Avalonia's /// bindings have no "and" and none of these is a single question any more. Everywhere else on this /// head that costs a wrapper element; here it would cost two nested ones per row and the header's would /// have to be an "or", which a wrapper cannot express at all. /// /// /// ◆ The nav stands down for a shell rather than for the terminal surface, and those parted company /// when that surface gained a page. Connections with nothing running is a box, a CONNECT button and /// the machines connected to before — see TerminalScreen.axaml — and none of that is worth the screen a /// shell is worth it for. It is also the one screen somebody can arrive at by closing their last tab, /// which made the collapsed bar a way to end up with no route to Hosts or Settings but the system back /// gesture. The header is deliberately not part of this: the surface draws its own bar with the back /// arrow and the +, and the vault header above that is the second row this head exists to avoid. /// /// /// The header is the one worth reading twice. Narrow, it stands down behind SETTINGS, because the /// screens under that hub draw their own header with a back arrow and two rows of chrome is what this /// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail /// is how you leave — so the vault's name, the sync light and LOCK stay where they are on every screen. /// Losing them on the keychain would be losing the only LOCK button on the surface. /// /// /// ◆ The header is now a swap rather than a switch, and the editor takes the whole screen. Two /// more flags and two more inputs, both of them the vault's rather than the shell's — see /// . While hosts are ticked the header stands down and /// puts the action bar in its place, which is what makes that bar /// unambiguous: the screen is about the ticked hosts and nothing else. While the host editor is open it /// is a page rather than a card, so all four rows of chrome stand down and the form has the display — /// which is what "opens in a separate page" means on a 360dp screen. /// /// /// Recomputed on every notification from either object rather than on a named list of them. Five boolean /// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed /// ones, and which of them raise a change is a fact about a file in another project that nothing here /// would notice going stale. /// /// private void RefreshChrome() { var wide = body.Bounds.Width >= WideAt; var pages = shell?.IsShowingPages == true; // ◆ Connections with nothing running, which is the terminal surface drawing a page: a box, a CONNECT // button and the machines connected to before. The nav stands down for a shell — the whole of the // arrangement below — and there is no shell here to stand down for, so it stays. Taking it away on // this one screen was worst where it was least affordable: somebody who has just closed their last // tab, or who pressed Connections to see what was open and found nothing, was left on a screen whose // only way to Hosts or Settings was the system back gesture. var connectPage = shell is { IsTerminalSurface: true, HasTabs: false }; // The editor is a page of its own now, so nothing else is drawn around it — not the vault header, // not the shells strip, and not the way off the screen. Its own header carries the back arrow, which // is the one control it needs and the one the system gesture already maps to. var editing = vault?.IsEditing == true; // Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by // navigating — and a bar counting hosts over the transfers screen would be chrome about a list that // is not on the display. var choosing = vault?.IsChoosingHosts == true && shell?.IsHostsShowing == true; // Before the flags, because it changes what one of them reads. Nothing else on this head navigates // in response to a resize, and this is not navigation for its own sake: the hub is a list of the // destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things // it can already see. Only from the hub itself — a screen reached through it stays put, because the // user asked for that screen rather than for the menu. if (wide && !IsWide && shell is { Screen: ShellScreen.More }) { shell.ShowScreenCommand.Execute(ShellScreen.Hosts); } IsWide = wide; ShowsRail = wide && (pages || connectPage) && !editing; ShowsBottomBar = !wide && (pages || connectPage) && !editing; ShowsShellStrip = pages && !editing; ShowsHostSelectionBar = pages && choosing && !editing; ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true); } private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (shell is null) { return; } FollowTheVault(); RefreshChrome(); if (e.PropertyName is nameof(MainWindowViewModel.State)) { ApplyScreenshotPolicy(shell.State); if (shell.State is not ShellState.Locked) { thisLockIsTheLaunch = false; offeredDeviceUnlock = false; } } // Three properties rather than one, because the condition is assembled out of order. Startup sets // State to Locked and only then awaits the keystore to decide CanUnlockWithDevice, and it does all // of it inside the busy wrapper — which refuses a second command outright. So whichever of the // three settles last is the one that has to ask again. if (e.PropertyName is nameof(MainWindowViewModel.State) or nameof(MainWindowViewModel.CanUnlockWithDevice) or nameof(MainWindowViewModel.IsBusy)) { TryOfferDeviceUnlock(); } } /// /// Raises the fingerprint prompt on arriving at the lock screen, rather than waiting to be asked. /// /// /// /// The button is still there and still says what it does; this only spends the tap for you. On a phone /// that is the difference between opening the application in one gesture and in two, and the second of /// the two was a button whose entire content was "yes, do the thing you already know I want". /// /// /// Only at launch. A lock the user asked for is not answered with a request to unlock — that /// turns LOCK into a control that appears to do nothing, and worse, trains the reflex of authenticating /// at a prompt that appeared without being asked for. So the offer belongs to the locked screen the /// process started on and to no other. /// /// /// Once. A declined gesture leaves the passphrase box exactly where it was, which is the whole /// fallback — and a prompt that reappeared after being dismissed would be a modal the user cannot get /// out of to type into it. /// /// /// Nothing here needs a failure path. UnlockWithDeviceAsync turns every refusal into a status /// line, and a phone with no enrolled fingerprint never gets here at all, because /// CanUnlockWithDevice already asked the keystore. /// /// private void TryOfferDeviceUnlock() { if (!thisLockIsTheLaunch || offeredDeviceUnlock) { return; } if (shell is not { State: ShellState.Locked, CanUnlockWithDevice: true, IsBusy: false } current) { return; } offeredDeviceUnlock = true; current.UnlockWithDeviceCommand.Execute(null); } /// protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); if (TopLevel.GetTopLevel(this) is { } top) { top.BackRequested += OnBackRequested; keyboard = top.InputPane; if (keyboard is not null) { keyboard.StateChanged += OnKeyboardChanged; ApplyKeyboardInset(keyboard); } } } /// protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { if (TopLevel.GetTopLevel(this) is { } top) { top.BackRequested -= OnBackRequested; } if (keyboard is not null) { keyboard.StateChanged -= OnKeyboardChanged; keyboard = null; } base.OnDetachedFromVisualTree(e); } private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e) => ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0); private void ApplyKeyboardInset(IInputPane pane) => ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0); /// /// Holds the phone's whole interface clear of the software keyboard. /// /// /// /// Here rather than on each screen, because the keyboard is not a screen's business. Five of them /// have a box that can be typed into and every one of them would need the same handler; a sixth added /// later would silently not have it. Everything the phone draws is inside Body, so one bottom /// margin shortens all of them at once — which is the same thing the window resizing would have done, /// and is why the two paths below never both apply. /// /// /// Two paths, one of which is dead on any given device. Before Android 15, the activity's /// AdjustResize makes the platform shorten the window itself and the keyboard inset reaches /// Avalonia already consumed — this measures zero and the margin stays where it is. From Android 15 the /// window is no longer resized for the keyboard at all, edge-to-edge being enforced, and the inset is /// reported instead: that is the number applied here. Adding a margin on top of a window that had /// already shrunk would strand the interface an entire keyboard above the keyboard, which is why the /// value is taken from the inset alone and never from both. /// /// /// Scrolling the box back into view is deliberately not done here. ScrollViewer already brings a /// newly focused child into view, and every screen with a box on it is inside one; what it cannot know /// is that the visible region shrank *after* the focus. So the trigger is the resize this margin causes /// — see — and not this method, which would run a layout pass too early to /// have anything to scroll to. /// /// private void ApplyKeyboardInset(double occluded) { var inset = double.IsFinite(occluded) ? Math.Max(occluded, 0) : 0; if (Math.Abs(body.Margin.Bottom - inset) > 0.5) { body.Margin = new Thickness(0, 0, 0, inset); } } /// /// Answers a resize: which chrome this surface wears, and where the keyboard left the focused box. /// /// /// /// ◆ Two jobs, and the chrome's is unconditional while the keyboard's is not. A rotation, an /// unfold and Android's freeform window all arrive here and all of them can cross the width at which /// this surface stops being a phone — so runs first and runs always. What /// follows it is the older job, and the early return below belongs to that one alone: it used to be the /// first thing in this method, which would have meant a foldable opening to a bottom bar until somebody /// typed something. /// /// /// The one moment the scroll is needed is the one no other handler sees: the box was focused while the /// whole screen was available, and the space it sits in shrank afterwards. Both ways of losing that /// space end here — the margin applied above, and the platform shortening the window on Android 14 and /// earlier — which is why the resize is the trigger rather than either of the two things that cause it. /// /// /// Posted rather than called, and at Loaded priority, because the size change is raised during /// the layout pass that caused it: asking a ScrollViewer to scroll to a child whose new bounds /// have not been written yet scrolls to where the child used to be. /// /// /// Only while the keyboard is up. Every rotation and every screen change resizes this control too, and /// a shell that scrolled to the focused control on each of them would be a shell that moves under you. /// /// private void OnBodyResized(object? sender, SizeChangedEventArgs e) { RefreshChrome(); if (keyboard is not { State: InputPaneState.Open }) { return; } Dispatcher.UIThread.Post( () => { // Whatever holds focus, not the passphrase box by name: this runs for eleven screens and // the one the keyboard is up for is the only one that can answer which box that is. if (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is Control focused) { focused.BringIntoView(); } }, DispatcherPriority.Loaded); } /// /// Takes the system back gesture up the hierarchy rather than out of the application. /// /// /// /// v2 is the first arrangement here with a second level: six destinations sit behind MORE, each with /// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first, /// and left unhandled it does not go up — it finishes the activity. Ending the application from a log /// screen is not a plausible reading of "back". /// /// /// Handled in the order the interface is stacked, not by screen alone: the terminal is a surface over a /// page, so it is dismissed before the page under it is considered. Setting Handled is what stops /// the event being re-dispatched to the activity's own default. /// /// /// Two things it deliberately does not do. It does not answer a host-key prompt — those are /// decisions with two named buttons, and a gesture that dismissed one would be the swipe-to-dismiss this /// head refused when it made the changed-key refusal a full-screen panel rather than a sheet. And from /// the host list it does nothing at all, so back still leaves the application from the screen the /// application opens on, which is what every other Android app does. /// /// /// v3 adds one guard above the switch rather than another case inside it. The switch's first case /// is the membership test of minus More itself, /// and has to stay in step with it — a screen added to the hub and not to that case would trap the user /// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet /// sits over the host editor's own screen, so back has to lower whatever is topmost before it considers /// moving between screens at all. Closing an editor is not the same refusal as leaving a host-key /// decision alone — an editor is abandonable by design, and the CANCEL button beside it says so. /// /// /// The connect menu is a second such guard, and it matters more than the first. A terminal now /// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way /// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the /// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a /// menu would take two, neither of them the one being looked at. /// /// private void OnBackRequested(object? sender, RoutedEventArgs e) { if (shell is not { State: ShellState.Unlocked } current) { return; } // A decision is on screen. Leave it alone — see the remark. if (current.Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true } || current.Transfers is { HasPendingHostKey: true } or { HasHostKeyMismatch: true }) { return; } // The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone // draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the // terminal because a gesture that dismissed the surface underneath a menu would close two things at // once — and the one the user was looking at would not be either of them. if (current.IsConnectSheetOpen) { current.CloseConnectSheetCommand.Execute(null); e.Handled = true; return; } if (TryCloseAnOpenEditor(current)) { e.Handled = true; return; } if (!current.IsShowingPages) { current.ShowScreenCommand.Execute(current.Screen); e.Handled = true; return; } switch (current.Screen) { case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults or ShellScreen.Keychain: // ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and // never draws SETTINGS, so backing out to it would land on a screen with no way off it but // a second back — and the arrows in these screens' own headers are hidden there for the // same reason. Home is where back goes when the thing you came from is not on the surface. current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More); e.Handled = true; break; case ShellScreen.More: current.ShowScreenCommand.Execute(ShellScreen.Hosts); e.Handled = true; break; default: // Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app. break; } } /// /// Lowers whatever the hosts screen has raised over its list, topmost first. /// /// Whether anything was closed, and so whether back has been spent. /// /// /// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit /// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the /// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather /// than the editor the user was looking at. /// /// /// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the /// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the /// next NEW HOST open on somebody else's abandoned draft. /// /// /// ◆ Selection mode is last and is still a thing back has to spend itself on. It is a mode rather /// than a surface — the list underneath is fully drawn and the only sign of it is the bar across the top /// — and a gesture that left the application from it would take somebody out of the app because they had /// held a row down. Its panels go before it, in the order they are stacked: the picker or the question is /// what the user is looking at, and the ticks underneath are what it is about. /// /// private static bool TryCloseAnOpenEditor(MainWindowViewModel current) { if (current.Vault is not { } vault) { return false; } return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault); } /// Lowers the nearest of the four sheets, which are what sits over everything else. /// /// The four cannot be open at once — each is raised from a control the others hide — so their order /// between themselves decides nothing. What matters is that all of them come before the panels and the /// editors underneath: closing an editor while a sheet was open would leave the sheet floating over a /// list nobody asked to see. /// private static bool TryLowerASheet(VaultViewModel vault) { // ◆ The action bar's own menu, first of the four because it is raised from chrome that is already // over everything else. if (vault.IsHostActionSheetOpen) { vault.CloseHostActionSheetCommand.Execute(null); return true; } // The password sheet, which is what a tap on a machine that wants one raises. Cancelled rather than // hidden, because cancelling is what empties the box — see VaultViewModel.CancelConnectPassword. if (vault.IsAskingForConnectPassword) { vault.CancelConnectPasswordCommand.Execute(null); return true; } if (vault.IsAddSheetOpen) { vault.CloseAddSheetCommand.Execute(null); return true; } if (vault.GroupSheet is not null) { vault.CloseGroupSheetCommand.Execute(null); return true; } return false; } /// Closes the nearest of the panels, the editors and selection mode itself. /// private static bool TryCloseSomethingBehindTheSheets(VaultViewModel vault) { if (vault.IsSendingChosenHostsToAVault) { vault.CancelSendChosenHostsToAVaultCommand.Execute(null); return true; } if (vault.IsRegroupingChosenHosts) { vault.CancelRegroupChosenHostsCommand.Execute(null); return true; } if (vault.IsConfirmingChosenHostDeletion) { vault.CancelDeleteCommand.Execute(null); return true; } if (vault.IsEditing) { vault.CancelEditCommand.Execute(null); return true; } if (vault.IsEditingGroup) { vault.CancelGroupEditCommand.Execute(null); return true; } if (vault.IsChoosingHosts) { vault.ClearHostChoiceCommand.Execute(null); return true; } return false; } /// /// Blocks screenshots and screen recording while the recovery code is on screen. /// /// /// /// The recovery screen says screenshots are blocked, and this is what makes that true rather than a /// claim. FLAG_SECURE is a window flag — no control can set it — so it lives here, on the one /// object that has the activity. /// /// /// It is worth being clear about what this buys. It stops the obvious accident — a screenshot /// of the only copy of an unrecoverable code landing in a cloud photo library — and it excludes the /// screen from the recent-apps thumbnail, which is the part users never think about. It stops nothing /// determined: a second phone photographs the screen perfectly well. The code is meant to be written /// down, and this only pushes people away from the one place it must not be written down to. /// /// /// Lowered again afterwards rather than left on. Leaving it set would make the terminal unscreenshotable /// too, and a screenshot of a shell is a thing people legitimately want. /// /// private static void ApplyScreenshotPolicy(ShellState state) { if (PhoneEnvironment.CurrentActivity?.Window is not { } window) { return; } if (state is ShellState.ShowingRecoveryCode) { window.SetFlags(WindowManagerFlags.Secure, WindowManagerFlags.Secure); } else { window.ClearFlags(WindowManagerFlags.Secure); } } }