Files
DodoSSH/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
T
jaap-jan 5b32c76c95
ci / build and test (push) Successful in 3m49s
ci / android head (push) Successful in 3m55s
ci / api image (push) Successful in 6s
ci / desktop nightly (push) Successful in 1m22s
Android: the redesign — a Vaults tab with hosts, keychain and snippets, card lists, a keychain that can add, and files that act on what is chosen
The bottom bar is Vaults · Connections · Settings, drawn as Material 3 pills with icons from the embedded
Material font and a count of open sessions on Connections, which replaces the strip of shells above the
bar. Vaults is the open vault: its header carries the name as a switch to the vaults screen, the sync
light and a lock icon, and a Hosts · Keychain · Snippets segmented control sits under it. The keychain
and snippets move there from Settings (IsVaultSurface lights the tab; IsMoreSurface no longer lists
them), and back from either goes to the hosts. Settings becomes an account card and grouped sections:
security, vaults and history, files, this app.

Host rows are cards with a terminal tile and the live dot on its corner. The connect sheet names the
host and its address, labels its box, focuses it as it opens, reveals the password on request, and puts
CANCEL left and CONNECT right; every two-button row on the head now confirms on the right. While a
hosts-screen sheet is up the header and bar are dimmed too and a tap on them lowers it, and the bar
stands down while the software keyboard is up.

The keychain gains a floating + and a sheet of the desktop's five adds (generate a key, paste a key, a
password, a bucket, a tag) over the editors VaultViewModel already had, drawn as a full-screen page with
labels above the boxes; a chosen item gets EDIT and COPY PUBLIC KEY beside MOVE and DELETE. Snippets get
a floating + and cards that say RUNS or INSERTS. Files trade the seven-button block for a selection bar
(save, and a ⋮ sheet with rename, move, permissions and a red delete), folder and file tiles, an upload
button, and header actions for a terminal on the same host, refresh and close; a terminal's + sheet
opens SFTP on that terminal's own host.

The theme takes the audit's visual findings: Fluent's ClipToBounds is cleared on glowing buttons so the
glow is no longer cut to a square (the desktop's Button.accent too), prose is Montserrat with mono kept
for addresses and code, disabled secondary buttons are quieter than enabled ones, Fluent's dropdown,
checkbox, switch, focus and selection brushes come from the palette, touch no longer leaves hover fills
behind, and lists, nav entries and icon buttons carry names for TalkBack.

Builds clean; checked on the emulator end to end: unlock, the three vault views, adding and deleting a
password, connecting through the new sheet, files on the terminal's host with the selection bar and
its sheet. App tests 727 of 733 pass with 5 skipped; the one failure, TheCountsAPhoneShows_
FollowEveryForwardsState, passes three runs of three alone and is untouched here. Layout tests pass.
2026-10-04 15:50:09 +02:00

953 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>The phone's single view. The desktop head's MainWindow, without the window.</summary>
internal sealed partial class PhoneShell : UserControl
{
private MainWindowViewModel? shell;
/// <summary>
/// The open vault, while there is one, so that this control hears about the hosts screen's own state.
/// </summary>
/// <remarks>
/// ◆ <b>A second subscription, and it is the price of the header being swappable.</b> 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 <see cref="OnShellChanged"/>, because <c>Vault</c> is replaced on
/// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive.
/// </remarks>
private VaultViewModel? vault;
/// <summary>
/// Everything the phone draws, which is the element the software keyboard is kept off.
/// </summary>
/// <remarks>
/// Looked up rather than read off the field the name generator declares for <c>x:Name</c>, and
/// <c>TerminalScreen</c> does the same for the same reason: that field is assigned by the generated
/// <c>InitializeComponent</c>, 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.
/// </remarks>
private readonly Panel body;
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
private IInputPane? keyboard;
/// <summary>
/// Whether the software keyboard is up, read from the pane's state rather than from the inset: before
/// Android 15 the window itself shrinks for the keyboard and the inset reads zero. See
/// <see cref="ApplyKeyboardInset(double)"/>.
/// </summary>
private bool keyboardOpen;
/// <summary>
/// Whether the lock screen currently showing is the one the application launched into.
/// </summary>
/// <remarks>
/// Set once, when the shell arrives, because this control is built once for the process. Cleared the
/// moment the state leaves <see cref="ShellState.Locked"/>, and never set again — which is what stops a
/// deliberate lock from being answered with an immediate request to unlock. See
/// <see cref="TryOfferDeviceUnlock"/>.
/// </remarks>
private bool thisLockIsTheLaunch;
private bool offeredDeviceUnlock;
/// <summary>
/// The width above which this surface is laid out like the desktop.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
private const double WideAt = 600;
public PhoneShell()
{
AvaloniaXamlLoader.Load(this);
body = this.FindControl<Panel>("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.Transfers.PropertyChanged -= OnVaultChanged;
}
shell = DataContext as MainWindowViewModel;
if (shell is not null)
{
shell.PropertyChanged += OnShellChanged;
// The files screen's actions sheet is the one sheet outside the hosts screen that the chrome
// scrim covers for, so its openness is heard here too. Transfers is built once with the shell
// and never replaced, unlike the vault.
shell.Transfers.PropertyChanged += OnVaultChanged;
ApplyScreenshotPolicy(shell.State);
thisLockIsTheLaunch = true;
TryOfferDeviceUnlock();
}
FollowTheVault();
RefreshChrome();
};
}
/// <summary>Moves this control's second subscription onto whichever vault is open now.</summary>
/// <remarks>
/// 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.
/// </remarks>
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();
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
/// <remarks>
/// 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
/// <c>$parent[views:PhoneShell]</c>.
/// </remarks>
public static readonly StyledProperty<bool> IsWideProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
/// <summary>Whether the rail down the left edge is drawn.</summary>
/// <remarks>
/// The wide surface's answer to <see cref="ShowsBottomBarProperty"/> and asks the same question about
/// which screen is up, so the two move together — including over Connections with nothing running. See
/// <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsRailProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
/// <remarks>
/// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal
/// surface drawing a page. See <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
/// <summary>Whether the header carrying the vault's name, the sync light and LOCK is drawn.</summary>
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
/// <summary>Whether the bar about the chosen hosts is drawn in the header's place.</summary>
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
/// <summary>Whether the HOSTS · KEYCHAIN · SNIPPETS switch is drawn under the vault's header.</summary>
/// <remarks>
/// Narrow surfaces only: a wide one has the rail, which lists the three already, and a switch repeating
/// three of the rail's entries an inch to the right of them would be the same choice offered twice.
/// </remarks>
public static readonly StyledProperty<bool> ShowsVaultTabsProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultTabs));
/// <inheritdoc cref="IsWideProperty" />
public bool IsWide
{
get => GetValue(IsWideProperty);
private set => SetValue(IsWideProperty, value);
}
/// <inheritdoc cref="ShowsRailProperty" />
public bool ShowsRail
{
get => GetValue(ShowsRailProperty);
private set => SetValue(ShowsRailProperty, value);
}
/// <inheritdoc cref="ShowsBottomBarProperty" />
public bool ShowsBottomBar
{
get => GetValue(ShowsBottomBarProperty);
private set => SetValue(ShowsBottomBarProperty, value);
}
/// <inheritdoc cref="ShowsVaultHeaderProperty" />
public bool ShowsVaultHeader
{
get => GetValue(ShowsVaultHeaderProperty);
private set => SetValue(ShowsVaultHeaderProperty, value);
}
/// <inheritdoc cref="ShowsHostSelectionBarProperty" />
public bool ShowsHostSelectionBar
{
get => GetValue(ShowsHostSelectionBarProperty);
private set => SetValue(ShowsHostSelectionBarProperty, value);
}
/// <summary>Whether the header and the bottom bar are dimmed under a sheet the hosts screen raised.</summary>
public static readonly StyledProperty<bool> ShowsSheetScrimProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsSheetScrim));
/// <inheritdoc cref="ShowsSheetScrimProperty" />
public bool ShowsSheetScrim
{
get => GetValue(ShowsSheetScrimProperty);
private set => SetValue(ShowsSheetScrimProperty, value);
}
/// <inheritdoc cref="ShowsVaultTabsProperty" />
public bool ShowsVaultTabs
{
get => GetValue(ShowsVaultTabsProperty);
private set => SetValue(ShowsVaultTabsProperty, value);
}
/// <summary>
/// Works out which chrome this surface should be wearing.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>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.</b> 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.
/// </para>
/// <para>
/// ◆ <b>The nav stands down for a shell rather than for the terminal surface, and those parted company
/// when that surface gained a page.</b> 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.
/// </para>
/// <para>
/// <b>The header is the one worth reading twice.</b> 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.
/// </para>
/// <para>
/// ◆ <b>Narrow, it is the VAULTS tab's header now, on all three of its screens.</b> The keychain and
/// snippets moved from behind SETTINGS to the vault's own switch (see
/// <see cref="MainWindowViewModel.IsVaultSurface"/>), so the header and the switch under it are drawn
/// over hosts, keychain and snippets alike, and those two screens' own headers stand down on a narrow
/// surface to make room.
/// </para>
/// <para>
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
/// <see cref="vault"/>. While hosts are ticked the header stands down and
/// <see cref="ShowsHostSelectionBar"/> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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 vault's switch, 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.
// The keychain's editor page counts too: it is the host editor's shape and wants the same screen.
var editing = vault?.IsEditing == true
|| (vault?.IsEditingAVaultItem == true && shell?.IsKeychainShowing == 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;
// ◆ And not while the software keyboard is up. The body shrinks to stay clear of the keyboard, so the
// bar rode up on top of it and took 72 pixels from whatever was being typed into — the connect sheet,
// most often (audit M12). Every Android app with a bottom bar puts it away for the keyboard.
ShowsBottomBar = !wide && (pages || connectPage) && !editing && !keyboardOpen;
ShowsHostSelectionBar = pages && choosing && !editing;
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsVaultSurface == true);
ShowsVaultTabs = ShowsVaultHeader && !wide;
ShowsSheetScrim = pages
&& ((vault is not null && HasASheetUp(vault))
|| (shell is { Transfers.IsEntryMenuOpen: true }
&& (shell.IsTransfersShowing || shell.IsBucketsShowing)));
}
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();
}
}
/// <summary>
/// Raises the fingerprint prompt on arriving at the lock screen, rather than waiting to be asked.
/// </summary>
/// <remarks>
/// <para>
/// 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".
/// </para>
/// <para>
/// <b>Only at launch.</b> 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.
/// </para>
/// <para>
/// <b>Once.</b> 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.
/// </para>
/// <para>
/// Nothing here needs a failure path. <c>UnlockWithDeviceAsync</c> turns every refusal into a status
/// line, and a phone with no enrolled fingerprint never gets here at all, because
/// <c>CanUnlockWithDevice</c> already asked the keystore.
/// </para>
/// </remarks>
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);
}
/// <inheritdoc />
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);
}
}
PhoneEnvironment.Resumed += OnActivityResumed;
}
/// <inheritdoc />
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
PhoneEnvironment.Resumed -= OnActivityResumed;
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested -= OnBackRequested;
}
if (keyboard is not null)
{
keyboard.StateChanged -= OnKeyboardChanged;
keyboard = null;
}
base.OnDetachedFromVisualTree(e);
}
/// <summary>
/// Re-reads the keyboard inset on the way back into the application.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The margin can be stale here and there is no other moment it gets corrected.</b> Android takes
/// the software keyboard away when this activity stops, and Avalonia's input pane raises no
/// <c>StateChanged</c> for it — the pane is gone with the window rather than closed in front of one. So
/// <see cref="ApplyKeyboardInset(double)"/> last ran for a keyboard that was up, and the interface comes
/// back holding a whole keyboard's worth of empty space it no longer needs. It is not subtle and it does
/// not recover: a third of the display, on every screen, until something opens and closes the keyboard.
/// </para>
/// <para>
/// Asking the pane rather than assuming zero, because resuming does not always mean the keyboard went.
/// A permission dialogue, the biometric sheet and the sign-in browser all pause this activity and can
/// all hand it back with the keyboard exactly as it was — and a handler that flattened the inset on the
/// way in would then strand the focused box under a keyboard that is genuinely there. The pane knows
/// which of the two happened; this only has to ask it again.
/// </para>
/// <para>
/// Posted rather than run inline. <c>OnResume</c> is early — the window has not necessarily been laid
/// out again and the pane's own state is settled by the insets that arrive with the next frame, so
/// reading it on this turn can return the state that is on the way out. Background priority puts the
/// read after that.
/// </para>
/// </remarks>
private void OnActivityResumed(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(
() =>
{
if (keyboard is not null)
{
ApplyKeyboardInset(keyboard);
}
},
DispatcherPriority.Background);
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
{
keyboardOpen = e.NewState is InputPaneState.Open;
ApplyKeyboardInset(keyboardOpen ? e.EndRect.Height : 0);
RefreshChrome();
}
private void ApplyKeyboardInset(IInputPane pane)
{
keyboardOpen = pane.State is InputPaneState.Open;
ApplyKeyboardInset(keyboardOpen ? pane.OccludedRect.Height : 0);
RefreshChrome();
}
/// <summary>
/// Holds the phone's whole interface clear of the software keyboard.
/// </summary>
/// <remarks>
/// <para>
/// <b>Here rather than on each screen, because the keyboard is not a screen's business.</b> 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 <c>Body</c>, 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.
/// </para>
/// <para>
/// <b>Two paths, one of which is dead on any given device.</b> Before Android 15, the activity's
/// <c>AdjustResize</c> 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.
/// </para>
/// <para>
/// Scrolling the box back into view is deliberately not done here. <c>ScrollViewer</c> 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 <see cref="OnBodyResized"/> — and not this method, which would run a layout pass too early to
/// have anything to scroll to.
/// </para>
/// </remarks>
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);
}
}
/// <summary>
/// Answers a resize: which chrome this surface wears, and where the keyboard left the focused box.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Two jobs, and the chrome's is unconditional while the keyboard's is not.</b> 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 <see cref="RefreshChrome"/> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Posted rather than called, and at <c>Loaded</c> priority, because the size change is raised during
/// the layout pass that caused it: asking a <c>ScrollViewer</c> to scroll to a child whose new bounds
/// have not been written yet scrolls to where the child used to be.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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);
}
/// <summary>
/// Takes the system back gesture up the hierarchy rather than out of the application.
/// </summary>
/// <remarks>
/// <para>
/// 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".
/// </para>
/// <para>
/// 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 <c>Handled</c> is what stops
/// the event being re-dispatched to the activity's own default.
/// </para>
/// <para>
/// <b>Two things it deliberately does not do.</b> 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.
/// </para>
/// <para>
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's second case
/// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> 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.
/// </para>
/// <para>
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> 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. The forwards sheet, raised from the same bar, shares the guard. 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.
/// </para>
/// </remarks>
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 or the forwards sheet, which are raised from the terminal's own bar and are the
// topmost thing the phone draws while one is up. Ahead of the editors below because they are nearer,
// and ahead of leaving the terminal because a gesture that dismissed the surface underneath a sheet
// would close two things at once — and the one the user was looking at would not be either of them.
if (TryCloseATerminalSheet(current))
{
e.Handled = true;
return;
}
// The files screen's actions sheet, raised from its selection bar.
if (current.Transfers.IsEntryMenuOpen)
{
current.Transfers.IsEntryMenuOpen = false;
e.Handled = true;
return;
}
if (TryCloseAnOpenEditor(current))
{
e.Handled = true;
return;
}
if (!current.IsShowingPages)
{
current.ShowScreenCommand.Execute(current.Screen);
e.Handled = true;
return;
}
if (TryGoUp(current))
{
e.Handled = true;
}
}
/// <summary>Moves one level up the screens, for the back gesture.</summary>
/// <returns>Whether it moved, and so whether back has been spent.</returns>
/// <remarks>See <see cref="OnBackRequested"/>, whose last step this is.</remarks>
private bool TryGoUp(MainWindowViewModel current)
{
switch (current.Screen)
{
// ◆ The vault's other two screens go back to its hosts rather than to the hub, since the redesign
// moved them under VAULTS: the switch at the top of the vault is where they were reached from, and
// HOSTS is where that switch starts.
case ShellScreen.Keychain or ShellScreen.Snippets:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
return true;
case ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults:
// ◆ 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);
return true;
case ShellScreen.More:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
return true;
default:
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
return false;
}
}
/// <summary>
/// Lowers whatever is up over the terminal: the customise sheet, the quick-access keyboard, the connect
/// menu or the forwards sheet.
/// </summary>
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
/// <remarks>
/// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
/// so at most one is ever open. The quick-access keyboard comes second because it is the one that can be
/// up with nothing over it — opening the customise sheet lowers it, so the two never compete either.
/// </remarks>
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
{
// The customise sheet, raised from the quick-access keyboard. DONE is the only other way off it, and
// every change on it has already been saved, so back loses nothing.
if (current.Keyboard.IsCustomizing)
{
current.Keyboard.CloseCustomizeCommand.Execute(null);
return true;
}
// The quick-access keyboard stands where the software keyboard stands, and back puts the software
// keyboard away without leaving the terminal — so it does the same for this. Only on the terminal
// surface: the flag outlives a trip to another screen, where there is no panel to lower.
if (current.Keyboard.IsQuickAccessOpen && current.IsTerminalShowing)
{
current.Keyboard.CloseQuickAccess();
return true;
}
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
return true;
}
if (current.IsForwardsSheetOpen)
{
current.CloseForwardsSheetCommand.Execute(null);
return true;
}
return false;
}
/// <summary>Whether one of the hosts screen's four sheets is up.</summary>
/// <remarks>The same four <see cref="TryLowerASheet"/> lowers, in the same terms.</remarks>
private static bool HasASheetUp(VaultViewModel vault) =>
vault.IsHostActionSheetOpen || vault.IsAskingForConnectPassword || vault.IsAddSheetOpen
|| vault.GroupSheet is not null || vault.IsKeychainAddSheetOpen;
/// <summary>A tap on the scrim over the header or the bar: lowers the sheet, as its own scrim does.</summary>
private void OnChromeScrimTapped(object? sender, RoutedEventArgs e)
{
if (shell is { Transfers.IsEntryMenuOpen: true })
{
shell.Transfers.IsEntryMenuOpen = false;
}
else if (shell?.Vault is { } open)
{
TryLowerASheet(open);
}
}
/// <summary>
/// Lowers whatever the hosts screen has raised over its list, topmost first.
/// </summary>
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// ◆ <b>Selection mode is last and is still a thing back has to spend itself on.</b> 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.
/// </para>
/// </remarks>
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
{
if (current.Vault is not { } vault)
{
return false;
}
return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault);
}
/// <summary>Lowers the nearest of the four sheets, which are what sits over everything else.</summary>
/// <remarks>
/// 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.
/// </remarks>
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.IsKeychainAddSheetOpen)
{
vault.CloseKeychainAddSheetCommand.Execute(null);
return true;
}
if (vault.GroupSheet is not null)
{
vault.CloseGroupSheetCommand.Execute(null);
return true;
}
return false;
}
/// <summary>Closes the nearest of the panels, the editors and selection mode itself.</summary>
/// <inheritdoc cref="TryCloseAnOpenEditor" path="/remarks" />
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 (KeychainScreen.TryCancelAnEditor(vault))
{
return true;
}
if (vault.IsEditingGroup)
{
vault.CancelGroupEditCommand.Execute(null);
return true;
}
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
return true;
}
return false;
}
/// <summary>
/// Blocks screenshots and screen recording while the recovery code is on screen.
/// </summary>
/// <remarks>
/// <para>
/// The recovery screen says screenshots are blocked, and this is what makes that true rather than a
/// claim. <c>FLAG_SECURE</c> is a window flag — no control can set it — so it lives here, on the one
/// object that has the activity.
/// </para>
/// <para>
/// <b>It is worth being clear about what this buys.</b> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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);
}
}
}