Files
DodoSSH/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.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

137 lines
6.8 KiB
C#

using DodoSSH.Client.Session;
using global::Android.Content;
using global::Android.OS;
using global::Android.Provider;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// The handful of facts about this phone that the platform-neutral layers need handed to them.
/// </summary>
/// <remarks>
/// <para>
/// Named <c>Phone</c> rather than <c>Android</c> because <c>Android.Runtime.AndroidEnvironment</c> already
/// exists and this type is not it.
/// </para>
/// <para>
/// Both members below are things docs/android-port.md called out as needing to come from the head rather
/// than be branched for inside the core: the profile directory, and a device name that is not
/// <c>Environment.MachineName</c>.
/// </para>
/// </remarks>
internal static class PhoneEnvironment
{
private static Context? context;
/// <summary>Captured once, from the launcher activity, before Avalonia starts.</summary>
/// <remarks>
/// The <em>application</em> context rather than the activity's. An activity is destroyed and recreated
/// on configuration changes this head does not declare as handled, and holding one in a static field is
/// the textbook Android leak; the application context lives as long as the process, which is exactly the
/// lifetime the composition root has.
/// </remarks>
public static void Attach(Context activity) =>
context = activity.ApplicationContext ?? activity;
/// <summary>Where this phone keeps its profile.</summary>
/// <remarks>
/// <c>filesDir</c> — per-app, non-roaming, not user-visible, and removed when the app is uninstalled.
/// <see cref="ClientPaths"/> asks for a local, non-roaming directory because two machines sharing one
/// cache file corrupts the outbox; on Android that is not merely satisfied but enforced by the platform,
/// since no other app can reach this path at all.
/// </remarks>
public static ClientPaths Paths =>
new(Require().FilesDir?.AbsolutePath
?? throw new InvalidOperationException("Android returned no filesDir for this application."));
/// <summary>
/// What this phone calls itself in connection and keychain log entries.
/// </summary>
/// <remarks>
/// <para>
/// <c>Environment.MachineName</c> returns <c>localhost</c> on Android, which would make every log entry
/// written from a phone indistinguishable from every other — the finding recorded in
/// docs/android-port.md §7.
/// </para>
/// <para>
/// <c>Settings.Global.DeviceName</c> is what the user themselves typed in Settings, so it is the name
/// they will recognise in a log written by a different device. It is null on phones that have never had
/// one set, and the fallback is the marketing model rather than the board name: a person reading a log
/// knows what a Pixel 8 is and does not know what <c>shiba</c> is.
/// </para>
/// </remarks>
public static string DeviceName
{
get
{
var chosen = Settings.Global.GetString(Require().ContentResolver, "device_name");
if (!string.IsNullOrWhiteSpace(chosen))
{
return chosen;
}
var model = Build.Model;
return string.IsNullOrWhiteSpace(model) ? "Android phone" : model;
}
}
/// <summary>Where this phone keeps copies that only have to survive the thing that made them.</summary>
/// <remarks>
/// <c>cacheDir</c> — per-app like <see cref="Paths"/>, and unlike it, reclaimable: Android deletes from
/// here when the device runs short of storage. That is the right trade for the upload staging in
/// <see cref="DocumentStaging"/>, whose files are worthless the moment their transfer finishes, and it
/// is why the profile is not kept here. The cost is stated rather than hidden: a file reclaimed under
/// storage pressure while its upload is still running fails that upload.
/// </remarks>
public static string CacheDirectory =>
Require().CacheDir?.AbsolutePath
?? throw new InvalidOperationException("Android returned no cacheDir for this application.");
/// <summary>
/// The activity currently on screen, or null while the app is backgrounded.
/// </summary>
/// <remarks>
/// Set and cleared by <c>MainActivity</c>. Separate from <see cref="Require"/> because the two have
/// genuinely different lifetimes and different uses: the application context is what outlives
/// everything and is right for starting services, and this is what a window flag or a system dialogue
/// needs — neither substitutes for the other.
/// </remarks>
public static global::Android.App.Activity? CurrentActivity { get; set; }
/// <summary>Raised each time the launcher activity comes back to the foreground.</summary>
/// <remarks>
/// <para>
/// ◆ <b>Exists because the software keyboard can go away without Avalonia being told.</b>
/// <c>IInputPane.StateChanged</c> is the only signal <c>PhoneShell</c> has for the keyboard opening and
/// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the
/// activity is being paused, and the pane is simply gone by the time anything could have reported it.
/// So the inset that holds the interface clear of the keyboard was still applied on the way back in,
/// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard
/// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels
/// dead, and staying that way until the keyboard was opened and closed again.
/// </para>
/// <para>
/// An event here rather than a call into the view, because the view is Avalonia's and this is Android's:
/// <c>MainActivity</c> already owns <c>OnResume</c> and already writes <see cref="CurrentActivity"/>
/// from it, and a static event is the same shape of hand-off one step further. Anything else that needs
/// to re-read platform state on the way back in can subscribe without <c>MainActivity</c> learning what
/// it is.
/// </para>
/// <para>
/// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is
/// given for the life of the process. <c>PhoneShell</c> attaches and detaches with its visual tree.
/// </para>
/// </remarks>
public static event EventHandler? Resumed;
/// <summary>Announces that the launcher activity is foreground again.</summary>
internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty);
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
public static Context Require() =>
context ?? throw new InvalidOperationException(
"PhoneEnvironment was read before MainActivity attached it.");
}