Merge main into the teams branch

Two conflicts, and both were two people counting the same things differently
rather than disagreeing about what the code should do.

PhoneShell's header comment. The branch made "the five hub screens"
numberless, because TEAMS made it six and a number in that sentence had
already gone stale once. Main corrected "three destinations" to "two" in the
same sentence, because giving a shell the whole phone took the terminal out of
the set the header is drawn on. Both are right and neither noticed the other:
the header now stays on the hub's screens and on the two top-level
destinations, which is Hosts and Keychain.

The manual checks. Both sides appended a Phase 10 — main added the software
keyboard and the phone's terminal surface as 10 and 11, the branch added
Teams. Nothing about them overlaps, so the resolution is to keep all three in
the order they were written and renumber Teams to Phase 12, its subsections
and the one cross-reference inside 12.1 with it. Main's two phases keep the
numbers they already carry in its history, since renumbering those would move
headings somebody may already have linked to.

Everything else merged without a conflict, and the two places worth checking
afterwards both held: IsMoreSurface and the first case of
PhoneShell.OnBackRequested each kept ShellScreen.Team alongside main's edits.
Those two are one fact in two places, so a merge that dropped Team from either
would have trapped the user on the teams screen with the MORE tab dark.

Verified after resolving: solution builds with no errors and no new warnings,
the Android head builds, and every suite passes — App 214, Layout 73, Api 162,
Infrastructure 34, Contracts 25, Session 54. App gained the three shell-flow
tests main brought with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:40:27 +02:00
co-authored by Claude Opus 5
15 changed files with 882 additions and 236 deletions
@@ -2,8 +2,10 @@ 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;
@@ -15,6 +17,20 @@ 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>
@@ -32,6 +48,13 @@ internal sealed partial class PhoneShell : UserControl
{
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)
@@ -132,6 +155,14 @@ internal sealed partial class PhoneShell : UserControl
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested += OnBackRequested;
keyboard = top.InputPane;
if (keyboard is not null)
{
keyboard.StateChanged += OnKeyboardChanged;
ApplyKeyboardInset(keyboard);
}
}
}
@@ -143,9 +174,99 @@ internal sealed partial class PhoneShell : UserControl
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>
@@ -177,6 +298,13 @@ internal sealed partial class PhoneShell : UserControl
/// 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)
{
@@ -192,6 +320,17 @@ internal sealed partial class PhoneShell : UserControl
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;