Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s

All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
This commit is contained in:
2026-07-31 21:43:11 +02:00
parent 81e7e6d939
commit 7a3a521c59
51 changed files with 2144 additions and 134 deletions
@@ -0,0 +1,180 @@
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using DodoSSH.Client.Shell.ViewModels;
// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
// implicit usings, so the bare name is ambiguous — the same collision the namespace itself causes
// for Android.App. See the note at the top of MainActivity.
using Application = Avalonia.Application;
using Button = Avalonia.Controls.Button;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 03 — the terminal, and the accessory key row under it.</summary>
internal sealed partial class TerminalScreen : UserControl
{
/// <summary>
/// The keys a software keyboard does not have.
/// </summary>
/// <remarks>
/// <para>
/// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
/// insert mode, Tab completes a path, Ctrl makes <c>^C</c> reachable, the arrows reach history, and the
/// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in
/// almost every command worth typing on a phone.
/// </para>
/// <para>
/// The order matches the design's row. It is scrollable rather than compressed, because shrinking ten
/// keys to fit 360dp produces ten targets too small to hit.
/// </para>
/// </remarks>
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
[
("esc", [0x1B], false),
("tab", [0x09], false),
("ctrl", [], true),
("|", [(byte)'|'], false),
("-", [(byte)'-'], false),
("~", [(byte)'~'], false),
("/", [(byte)'/'], false),
// The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
// the difference between CSI A and SS3 A is the difference between working in bash and not.
("↑", [0x1B, (byte)'[', (byte)'A'], false),
("↓", [0x1B, (byte)'[', (byte)'B'], false),
("←", [0x1B, (byte)'[', (byte)'D'], false),
("→", [0x1B, (byte)'[', (byte)'C'], false),
];
/// <summary>
/// Whether the next ordinary key should be sent as a control character.
/// </summary>
/// <remarks>
/// Latching rather than held. Holding a modifier while typing needs two hands and a keyboard that
/// reports chords, and this row has neither — so Ctrl is pressed, then C, and releases itself. Every
/// Android SSH client does this and users expect it.
/// </remarks>
private bool controlLatched;
private Button? controlKey;
public TerminalScreen()
{
AvaloniaXamlLoader.Load(this);
BuildAccessoryRow();
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
DataContextChanged += (_, _) =>
{
if (DataContext is MainWindowViewModel shell)
{
this.FindControl<NativeWebView>("Renderer")!.Source = shell.TerminalPageUrl;
}
};
}
private void BuildAccessoryRow()
{
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
foreach (var (label, bytes, latches) in Keys)
{
var key = new Button
{
Content = new TextBlock
{
Text = label,
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
// 44 wide as well as tall. The design draws them flexed across the width, which at 360dp
// with ten keys is 32 pixels each — under every thumb-target guideline there is.
MinWidth = 44,
Height = 34,
Padding = new Thickness(10, 0),
CornerRadius = new CornerRadius(5),
Background = new SolidColorBrush(Color.Parse("#161B19")),
BorderBrush = new SolidColorBrush(Color.Parse("#232927")),
BorderThickness = new Thickness(1),
Foreground = new SolidColorBrush(Color.Parse("#B7C0BB")),
HorizontalContentAlignment = HorizontalAlignment.Center,
};
if (latches)
{
controlKey = key;
key.Click += (_, _) => ToggleControl();
}
else
{
key.Click += (_, _) => SendAsync(bytes);
}
row.Children.Add(key);
}
}
private void ToggleControl()
{
controlLatched = !controlLatched;
if (controlKey is null)
{
return;
}
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
// ^L to a database prompt believing they typed an l.
controlKey.Background = new SolidColorBrush(
Color.Parse(controlLatched ? "#243A2F" : "#161B19"));
controlKey.Foreground = new SolidColorBrush(
Color.Parse(controlLatched ? "#3CE88F" : "#B7C0BB"));
}
/// <remarks>
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
/// 0x1A, which is letter &amp; 0x1F. Applied only to letters, because Ctrl with an arrow key is a
/// different sequence entirely and silently mangling one into a control byte would be worse than
/// ignoring the latch.
/// </remarks>
private void SendAsync(byte[] bytes)
{
if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell)
{
return;
}
var payload = bytes;
if (controlLatched && bytes.Length == 1)
{
var c = bytes[0];
if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z')
{
payload = [(byte)(c & 0x1F)];
}
ToggleControl();
}
// Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has
// gone, and a button handler that awaited would serialise the row behind a slow link.
_ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask();
}
/// <summary>Sends a literal string, for the keys that carry text rather than a control code.</summary>
/// <remarks>Kept because the snippet feature will type into a terminal exactly this way.</remarks>
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
}