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,150 @@
using DodoSSH.Client.Auth;
using global::Android.Content;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Opens the authorization page in the system browser.
/// </summary>
/// <remarks>
/// <para>
/// <c>Process.Start</c> does not exist on this platform, so the desktop head's launcher cannot be reused —
/// but the rule it enforces is the same one and is enforced here too: the <em>system</em> browser, never an
/// embedded WebView. RFC 8252 §8.12 — an embedded user-agent can read the user's credentials as they are
/// typed, hides the real address bar, and cannot reuse an existing single-sign-on session.
/// </para>
/// <para>
/// An <c>ACTION_VIEW</c> intent rather than a Custom Tab. A Custom Tab is the nicer surface and shares the
/// browser's cookie jar just as this does, but it needs the <c>androidx.browser</c> package; this needs
/// nothing and satisfies the same requirement. Worth revisiting when something else pulls AndroidX in.
/// </para>
/// </remarks>
internal sealed class AndroidBrowserLauncher : IBrowserLauncher
{
/// <inheritdoc />
public Task OpenAsync(Uri url, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(url);
cancellationToken.ThrowIfCancellationRequested();
// Asserted rather than assumed, exactly as the desktop launcher does: an ACTION_VIEW on some other
// scheme is a request to open whatever app claims it, and this only ever opens an authorize URL.
if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
&& !string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
{
throw new ArgumentException($"Refusing to open a '{url.Scheme}' URL.", nameof(url));
}
var intent = new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(url.AbsoluteUri));
// NewTask because the launch comes from an application context rather than an activity one.
intent.AddFlags(ActivityFlags.NewTask);
PhoneEnvironment.Require().StartActivity(intent);
return Task.CompletedTask;
}
}
/// <summary>
/// Receives the authorization response as an intent rather than on a socket.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why not the loopback listener.</b> RFC 8252 §7.3's loopback redirect assumes a machine where the
/// application's own port is the application's own. On a phone it is not: any installed application can
/// bind a loopback port and, on a race, take the authorization code. §8.3 names this, and the platform's
/// answer is to have the system route the redirect to the registered application instead.
/// </para>
/// <para>
/// <b>A private-use scheme, and its limit is worth stating.</b> The redirect is
/// <c>dev.dodotech.dodossh:/oauth</c> — the reversed package name, which RFC 8252 §7.1 recommends because
/// it is a namespace the application demonstrably controls. It is *not* an Android App Link, and the
/// difference is real: another application can also declare this scheme, and Android will offer the user a
/// chooser rather than refusing. What stops that being a compromise is PKCE — the code is useless without
/// the verifier, which never leaves this process — plus the state check. An App Link would close the gap
/// properly, at the cost of hosting an assetlinks.json on the DodoSSH server's own domain; that is the
/// upgrade, and it is a server change rather than a client one.
/// </para>
/// <para>
/// The completion HTML is ignored. There is no tab of this application's own to write it into: the system
/// hands the intent over and closes the browser itself.
/// </para>
/// </remarks>
internal sealed class AndroidRedirectCallback : IAuthorizationCallback
{
/// <summary>The scheme, which must match the intent filter on MainActivity exactly.</summary>
internal const string Scheme = "dev.dodotech.dodossh";
private readonly TaskCompletionSource<CallbackResult> completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static AndroidRedirectCallback? waiting;
/// <param name="path">
/// The redirect path, from OidcClientOptions. Carried through so the desktop's configured value and
/// this one cannot silently differ.
/// </param>
public AndroidRedirectCallback(string path)
{
RedirectUri = new Uri($"{Scheme}:{(path.StartsWith('/') ? path : "/" + path)}");
// Exactly one sign-in can be outstanding, because exactly one activity receives the intent. A
// second overlapping attempt would leave the first waiting forever on a response the second
// consumed, so the earlier one is failed rather than orphaned.
Interlocked.Exchange(ref waiting, this)?.completion.TrySetCanceled();
}
/// <inheritdoc />
public Uri RedirectUri { get; }
/// <summary>Hands a received redirect to whichever sign-in is waiting for it.</summary>
/// <remarks>
/// Called from <c>MainActivity.OnNewIntent</c>. Returns quietly when nothing is waiting: a redirect
/// can arrive after the app was killed and relaunched, and that is a stale response rather than an
/// error worth showing anybody.
/// </remarks>
internal static void Complete(Uri redirect)
{
var target = Volatile.Read(ref waiting);
if (target is null)
{
return;
}
var parameters = new Dictionary<string, string>(StringComparer.Ordinal);
// The query, and the fragment if the provider used one. Response parameters arrive in the query
// for a code flow; reading both costs nothing and means a provider that answers errors in the
// fragment does not look like a silent hang.
foreach (var part in new[] { redirect.Query, redirect.Fragment })
{
foreach (var pair in part.TrimStart('?', '#').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var split = pair.Split('=', 2);
parameters[Uri.UnescapeDataString(split[0])] =
split.Length == 2 ? Uri.UnescapeDataString(split[1]) : string.Empty;
}
}
target.completion.TrySetResult(new CallbackResult(parameters));
}
/// <inheritdoc />
public Task<CallbackResult> WaitForCallbackAsync(
string completionHtml,
CancellationToken cancellationToken) =>
completion.Task.WaitAsync(cancellationToken);
/// <inheritdoc />
public void Dispose()
{
// Only if it is still this one. A later sign-in has already replaced it, and clearing the slot
// then would silently break the attempt that is actually in flight.
Interlocked.CompareExchange(ref waiting, null, this);
completion.TrySetCanceled();
}
}
@@ -1,11 +1,10 @@
using DodoSSH.Client.Session;
using global::Android.App;
using global::Android.Security.Keystore;
using global::Java.Security;
using global::Javax.Crypto;
using global::Javax.Crypto.Spec;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
@@ -1,9 +1,8 @@
using DodoSSH.Client.Session;
using global::Android.Content;
using global::Android.OS;
using global::Android.Provider;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
@@ -78,6 +77,17 @@ internal static class PhoneEnvironment
}
}
/// <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>The application context, once <see cref="Attach"/> has run.</summary>
public static Context Require() =>
context ?? throw new InvalidOperationException(