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
+43 -22
View File
@@ -4,6 +4,7 @@ using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
using DodoSSH.Client.Android.Views;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.Shell.ViewModels;
@@ -86,49 +87,69 @@ public sealed partial class DodoSshApp : Avalonia.Application
// its own, which is the half that would otherwise leave a notification up over nothing.
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
keepAlive.Refresh();
return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) };
}
/// <remarks>
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
/// the platform graph, and below it is the shell every head shares.
/// </remarks>
private static MainWindowViewModel ComposeShell(
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
SshNetConnectionFactory connections)
{
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
// key. A straight implementation of the interface the session layer has always taken.
var deviceKeys = new AndroidDeviceKeyStore(paths);
var browser = new AndroidBrowserLauncher();
var viewModel = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
SignInIsNotBuiltHere,
// Difference 4: the system browser by intent, and the response by intent too rather than on a
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
.ConfigureAwait(false),
TimeProvider.System,
connections,
passphraseProfile: null);
passphraseProfile: null,
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
// makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false));
// Started rather than awaited: framework initialisation must not block on a schema migration. The
// view model shows its own progress and handles its own failures.
_ = viewModel.StartAsync(CancellationToken.None);
keepAlive.Refresh();
return new PhoneShell { DataContext = viewModel };
return viewModel;
}
/// <summary>
/// Difference 4, and the one that is a refusal rather than an implementation.
/// Difference 4: where the authorization response comes back to.
/// </summary>
/// <remarks>
/// <para>
/// Signing in needs a redirect this head has not got. The desktop client receives the authorization
/// response on a loopback <c>TcpListener</c> (RFC 8252 §7.3), and reusing that here would be a
/// security regression rather than a shortcut: on a shared device any other application can bind a
/// loopback port and race for the response, which is the attack §8.3 names and the reason app links
/// exist. <c>Process.Start</c> does not exist on this platform either.
/// </para>
/// <para>
/// So this throws rather than half-working, and the shell never reaches it: <c>NeedsServer</c> draws a
/// screen that says the same thing in the user's words. A phone enrolled from the desktop client
/// unlocks here perfectly well, because unlocking needs no network at all.
/// </para>
/// The only thing this head changes about signing in, and it changes it for a security reason rather
/// than a platform one. The desktop receives the response on a loopback <c>TcpListener</c>; on a phone
/// any other installed application can bind a loopback port and race for the authorization code, which
/// is the attack RFC 8252 §8.3 names. Android routes a registered redirect to this application
/// instead — see <see cref="AndroidRedirectCallback"/>, including what a private-use scheme does and
/// does not protect against.
/// </remarks>
private static Task<IVaultServer> SignInIsNotBuiltHere(Uri serverUrl, CancellationToken cancellationToken) =>
throw new NotSupportedException(
"Signing in is not built on the Android head yet: it needs an app-link redirect rather than "
+ "the desktop client's loopback listener. See docs/android-port.md §5.");
private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
}