Files
DodoSSH/src/DodoSSH.Client.Android/MainActivity.cs
T
jaap-jan aff776cb42
ci / build and test (push) Failing after 3m0s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m56s
Rebuild the terminal's keypad, and fix what a device run showed
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.
2026-08-26 13:28:59 +02:00

135 lines
6.9 KiB
C#

// The Android SDK's own namespaces are reached through `global::` throughout this project, and it is not
// a style choice. This assembly's root namespace ends in `Android`, so inside it a bare `Android.App`
// binds to `DodoSSH.Client.Android.App` — this head's own Avalonia application type — rather than to the
// platform. The desktop head hit the same class of collision and answered it by renaming its type; here
// the collision is in the namespace itself, so the qualification is the honest fix.
using Avalonia.Android;
using DodoSSH.Client.Android.Platform;
using global::Android.App;
using global::Android.Content;
using global::Android.Content.PM;
using global::Android.Views;
namespace DodoSSH.Client.Android;
/// <summary>
/// The launcher activity.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately empty. Avalonia is configured on the application object — see
/// <see cref="DodoSshAndroidApplication"/> — and the desktop head's <c>Program.Main</c> has no counterpart
/// at all here: Android constructs the activity, and its <c>[STAThread]</c> is a WebView2 requirement that
/// means nothing on this platform.
/// </para>
/// <para>
/// <b>The ConfigurationChanges list is load-bearing.</b> Without it Android destroys and recreates the
/// activity on every rotation and every time the software keyboard appears — and this head holds live SSH
/// sessions and a terminal data plane behind a process-wide composition root. Letting the activity restart
/// would tear the Avalonia application down under them. Declaring the changes handled is what keeps a
/// shell alive across turning the phone sideways, which is the same promise the foreground service makes
/// about backgrounding, arrived at from a different direction.
/// </para>
/// <para>
/// <c>SingleTask</c> for the sign-in redirect: the authorization response comes back as an intent, and any
/// other launch mode answers it with a second copy of this activity on top of the first — which on this
/// head would mean a second Avalonia application over a live one.
/// </para>
/// <para>
/// <b><c>AdjustResize</c> is declared rather than left unspecified</b>, and it is half of how this head
/// keeps the software keyboard off the box being typed into; <c>PhoneShell</c> is the other half. Left
/// unspecified, Android chooses, and what it chooses for a window whose entire content is one native view
/// — which is what an Avalonia surface is — is to pan: it slides the window up by however much it thinks
/// the focused *native* view needs, and since that view is the whole surface, the passphrase box goes on
/// sitting under the keyboard. Resizing instead makes the window shorter, which the layout inside it can
/// answer, and a screen built around a <c>ScrollViewer</c> then scrolls the focused box into view by
/// itself. On Android 15 and later this attribute is ignored — edge-to-edge is enforced there and the
/// window is no longer resized for the keyboard — which is precisely the case PhoneShell handles from the
/// reported inset. The two are complementary and never both in effect: where the window resizes, the
/// keyboard inset arrives already consumed and measures zero.
/// </para>
/// </remarks>
[Activity(
// The launcher reads this rather than the application's, so a channel that renamed only the
// application element would still put two identical entries on the home screen. A resource because
// this is a compile-time string and the two channels need different answers from one binary's
// sources. See Resources/values/strings.xml.
Label = "@string/app_name",
Theme = "@style/DodoTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTask,
WindowSoftInputMode = SoftInput.AdjustResize,
ConfigurationChanges = ConfigChanges.Orientation
| ConfigChanges.ScreenSize
| ConfigChanges.ScreenLayout
| ConfigChanges.SmallestScreenSize
| ConfigChanges.KeyboardHidden
| ConfigChanges.UiMode)]
// The intent filter is what makes the sign-in redirect reach this application rather than a loopback
// socket. The scheme is the reversed package name — RFC 8252 §7.1 — and it must match the constant on
// AndroidRedirectCallback exactly; they are two declarations of one fact, so the constant is referenced
// here rather than retyped and a rename cannot break only one of them.
[IntentFilter(
[Intent.ActionView],
Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable],
DataScheme = AndroidRedirectCallback.Scheme)]
public sealed class MainActivity : AvaloniaMainActivity
{
/// <inheritdoc />
/// <remarks>
/// Resuming is also how a cancelled sign-in is noticed. There is no event for a user pressing back out
/// of the login page — the browser simply goes away and this application is foreground again with
/// nothing delivered — so being resumed while a sign-in is still waiting for its redirect is the
/// signal, and the only one there is. See <see cref="AndroidRedirectCallback.Abandon"/>, which is a
/// no-op unless a browser was opened and has not answered.
/// </remarks>
protected override void OnResume()
{
base.OnResume();
PhoneEnvironment.CurrentActivity = this;
AndroidRedirectCallback.Abandon();
// And the keyboard may have gone away while this activity was not on screen, without Avalonia's
// input pane raising anything to say so. PhoneShell re-reads the inset from this. See
// PhoneEnvironment.Resumed, which carries the whole of why.
PhoneEnvironment.RaiseResumed();
}
/// <inheritdoc />
/// <remarks>
/// Cleared only if it is still this activity. Android may resume the next one before pausing this one,
/// and clearing unconditionally would drop a reference the newcomer had just set.
/// </remarks>
protected override void OnPause()
{
base.OnPause();
if (ReferenceEquals(PhoneEnvironment.CurrentActivity, this))
{
PhoneEnvironment.CurrentActivity = null;
}
}
/// <inheritdoc />
/// <remarks>
/// <c>OnNewIntent</c> rather than <c>OnCreate</c>, and that is what <c>SingleTask</c> above buys: the
/// activity is already running with a sign-in waiting inside it, so the redirect has to be delivered
/// into that instance. Any other launch mode would start a second copy of the activity — and with it a
/// second Avalonia application over a live one — leaving the original waiting for a response that had
/// already been consumed.
/// </remarks>
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
if (intent?.Data is { } data
&& string.Equals(data.Scheme, AndroidRedirectCallback.Scheme, StringComparison.Ordinal)
&& Uri.TryCreate(data.ToString(), UriKind.Absolute, out var redirect))
{
AndroidRedirectCallback.Complete(redirect);
}
}
}