Public Access
Two of main's changes land in files this branch rewrote, and both needed carrying across by hand rather than by the merge. The phone's nav staying up on Connections with nothing running is a fourth input to RefreshChrome, which this branch had already given two more — whether hosts are ticked and whether the host editor is filling the screen. They compose: the rail and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped terminal surface keeps its way off the screen and the editor still takes the whole display. The key question under the host's move panel is the harder one, because this branch deleted the panel it was added to. The connect card is gone and the phone's only route to a move is the action bar, so leaving the merge to take this side would have removed a capability main had just shipped — silently, since nothing would fail to build. It is asked in the action bar's own picker instead, in two shapes fewer than the desktop's: one host, because which key to carry is a fact about one machine and a selection of six has six answers, and a move rather than a copy, because taking the key out from under an original that is staying put would leave that original unable to connect. BindingOfTheMovingHost splits into MovableBindingOf so both heads answer it the same way from different panels. Main also fixed a real trap in the same commit — a host that only inherited its key from its group arrived in the destination naming nothing at all, because the group stays behind — and the batch move had the same bug for the same reason. It goes through Detached now, which is where that fix lives. The carried host is written as the carry left it rather than being detached again, which is the one thing worth measuring: the key takes a new id over there, so a run that rebuilt the payload from the row would send the machine across naming a tombstone. Both directions are pinned, along with the rule about which shapes the question is asked in at all.
764 lines
34 KiB
C#
764 lines
34 KiB
C#
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 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 = DataContext as MainWindowViewModel;
|
||
|
||
if (shell is not null)
|
||
{
|
||
shell.PropertyChanged += OnShellChanged;
|
||
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 strip of open shells above the bottom bar is drawn.</summary>
|
||
public static readonly StyledProperty<bool> ShowsShellStripProperty =
|
||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
|
||
|
||
/// <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);
|
||
}
|
||
|
||
/// <inheritdoc cref="ShowsShellStripProperty" />
|
||
public bool ShowsShellStrip
|
||
{
|
||
get => GetValue(ShowsShellStripProperty);
|
||
private set => SetValue(ShowsShellStripProperty, 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>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 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();
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
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);
|
||
|
||
/// <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 first 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. 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, 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;
|
||
}
|
||
}
|
||
|
||
/// <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.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 (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);
|
||
}
|
||
}
|
||
}
|