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
+55 -3
View File
@@ -3,10 +3,11 @@
// 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 global::Android.App;
using global::Android.Content.PM;
using Avalonia.Android;
using DodoSSH.Client.Android.Platform;
using global::Android.App;
using global::Android.Content;
using global::Android.Content.PM;
namespace DodoSSH.Client.Android;
@@ -45,6 +46,57 @@ namespace DodoSSH.Client.Android;
| 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>
/// <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>
/// <inheritdoc />
protected override void OnResume()
{
base.OnResume();
PhoneEnvironment.CurrentActivity = this;
}
/// <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 />
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);
}
}
}