Public Access
Six defects, all found by running this head on an emulator against a local API rather than by reading it. They are in one commit because two of the files carry several of them and splitting those would mean hand-editing patches; each is argued at its own site. ── the keypad, which is most of this ─────────────────────────────────────────────────────────────────── It was eleven 30-by-44 keys in a horizontal ScrollViewer, and it was wrong twice over. A pan and a press begin with the same few pixels of travel, so a thumb coming down at an angle — which is how a thumb comes down — was read as the start of a pan and the press was never delivered; the row slid slightly instead. And 30 tall is well under the 48 Android asks for, so the presses that did arrive were hard to aim. The arrows, the keys pressed most and in sequence, were the ones parked past the right-hand edge. Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it: they divide the width evenly and add a second row when the set does not fit. That is not convention for its own sake — a key whose position is fixed can be hit without looking, which is the whole point of a modifier row used while reading the output above it. So: two UniformGrid rows, every key an equal share of the width, nothing scrollable, 40 tall. Every key kept. Arrows on the lower row where the thumb rests, reordered to ← ↓ ↑ → so the pairs that undo each other are neighbours. It costs about 51 pixels of terminal, four or five lines, and that is the right trade: the row that was cheaper did not work. The keys also gain a pressed state, which they had none of. They are Focusable="False" so that using one does not take the keyboard off the terminal, and a button that never takes focus never shows the adorner Fluent would otherwise give it — leaving no feedback at all on a control whose only job is to be pressed. Their looks move from assignments in the code-behind to Button.hotkey, which is what lets a style say :pressed, and the Ctrl latch becomes a class rather than two brushes written by hand. ── and five smaller ones ──────────────────────────────────────────────────────────────────────────────── The keyboard's inset was never cleared when the app was resumed without the keyboard it was applied for. Android takes the keyboard away as the activity stops and Avalonia's input pane raises no StateChanged, the pane going with the window rather than closing in front of one — so the interface came back holding a whole keyboard's height of empty canvas. 838 of 2340 pixels on a Pixel 5, on every screen, until something opened and closed the keyboard. MainActivity now raises PhoneEnvironment.Resumed and PhoneShell re-asks the pane. It asks rather than assuming zero: a permission prompt or the biometric sheet can hand the activity back with the keyboard genuinely still up, and flattening the inset there would strand the focused box under a real one. TextBlock.detail never wrapped, where .body beside it always has. A block that neither wraps nor trims lays out at its full single-line width and the parent clips it, with no ellipsis to say anything is missing — so the add sheet read "A machine to connect to, filed under a group if you" and stopped. Which of the fifty-odd .detail blocks overflows is a function of width and font scale rather than of the string, so this is a style rather than an attribute on the handful visibly wrong on one phone. The nine that genuinely want one line now say NoWrap beside their TextTrimming, which is the pairing that was always meant. The two port boxes are NumericUpDowns and were the only unthemed controls on this head — Fluent's border and fill, next to a TextBox.field sharing their row. App.axaml has styled NumericUpDown for the desktop all along; this head never had one. Three buttons classed "icon accent" were painting Fluent's own Button.accent, #6275AC, a blue in no palette here. The desktop overrides that style and this head did not, so the same class read correctly there and not here. The host key sheet said "First contact with 10.0.2.2 · 2222 . Nothing in this keychain…" — Avalonia puts a space between adjacent Runs and the last one began with the full stop. A gap before a full stop, on the one dialogue whose whole job is to be read carefully before it is answered. ── checks ────────────────────────────────────────────────────────────────────────────────────────────── manual-checks gains 10.4a and 11.10f. 10.4 already covered dismissing the keyboard with back or the chevron but not leaving the app with it up, which is the path the inset bug shipped through; 10.4a covers both directions, since a fix that flattened the inset on every resume would pass one half and fail the other. 11.10f guards the keypad against a ScrollViewer being put back around it.
805 lines
36 KiB
C#
805 lines
36 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);
|
||
}
|
||
}
|
||
|
||
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)
|
||
=> 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);
|
||
}
|
||
}
|
||
}
|