Files
DodoSSH/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
T
jaap-jan 50fa6fba38 Let the phone delete a host, and move or remove a group
The desktop gained three things the phone did not follow: moving a group to
another vault, the second question asking whether a group's deletion takes its
hosts with it, and — since long before either — deleting a host at all. What
that left was a head whose v3 + can fill a keychain and whose editors can
correct one, with no way to empty either.

The commands could not simply be bound. DeleteGroup and MoveGroup aim at
GroupTarget, which is the selected card or the open group, and the phone has
neither: its list draws headings, and a heading's selection deliberately bounces
back to the host. Called bare on that head they would have returned having done
nothing — a DELETE that appears to have been pressed and has not. Both now take
the row and fall back to GroupTarget for the desktop's menu, and
ConfirmMoveGroupAsync resolves from the panel's own movingGroupId rather than
from the selection, which is also the honester answer on the desktop: what moves
is the shelf the panel was opened on.

The heading's pencil became a menu. Three icons after a chevron, a name, a vault
badge and a count is what would be left of the name at 360dp, so the ⋯ raises
the add sheet's shape carrying Edit, Move to another vault, a rule, and Delete —
the desktop's card menu, in the one idiom this screen already has. It does not
carry Open: the desktop's grid holds one level of the group tree and this list
holds all of it flattened, so there is nowhere to open a group into.

DELETE under a host sits on a row of its own beneath EDIT and MOVE rather than
beside them. A phone has no hover and no tooltip, so where a thumb lands is the
only thing separating a destructive control from an ordinary one. Both questions
take the controls that asked them — ShowsConnectControls, which is the phone's
half of the rule ShowsHostPaneActions already carries for the desktop's drawer —
so DELETE cannot be pressed a second time underneath its own confirmation.

Preferences gained the running version, and the sentence saying this head does
not replace itself and that no DodoSSH server will ever offer one. It reads
Updates.CurrentVersion off the same view model the desktop's UPDATES section
does, over the null channel that reports itself unsupported.

Nothing was needed for the realtime push: it is composed in ServerConnection,
which both heads use.

Seven tests, all phone-shaped — a group acted on with nothing selected, the menu
waved away leaving nothing armed, the ungrouped heading raising none, and the
bar's three states. The rectangles remain unmeasurable for the reason phase 8
gives; the checks for them are 8.10 to 8.13 and 13.6.
2026-08-04 19:58:10 +02:00

460 lines
19 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>
/// 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;
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();
}
};
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (shell is null)
{
return;
}
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>
/// Scrolls whatever has the keyboard back into view once the room left for it is known.
/// </summary>
/// <remarks>
/// <para>
/// The one moment this 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)
{
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:
current.ShowScreenCommand.Execute(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 two sheets sit over the list and the two 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>
/// </remarks>
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
{
if (current.Vault is not { } vault)
{
return false;
}
if (vault.IsAddSheetOpen)
{
vault.CloseAddSheetCommand.Execute(null);
return true;
}
// The other sheet, and it is checked beside the first rather than after the editors for the same
// reason: it is raised over the list, so it is the nearest thing on screen. The two cannot be open
// at once — one is raised by the +, the other by a heading, and each hides the list the other's
// control is on — so their order between themselves decides nothing.
if (vault.GroupSheet is not null)
{
vault.CloseGroupSheetCommand.Execute(null);
return true;
}
if (vault.IsEditing)
{
vault.CancelEditCommand.Execute(null);
return true;
}
if (vault.IsEditingGroup)
{
vault.CancelGroupEditCommand.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);
}
}
}