Files
DodoSSH/src/DodoSSH.Client.Android/App.axaml.cs
T
jaap-janandClaude Opus 5 5593f337b6 Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts,
Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the
last. The first design's four had nothing behind them, which is what made a hub worth building.

The palette moved from green-black to blue-black, and it moved in the shared project because that is
where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning
rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so
Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were
the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which
finally separates them: Live is that green and nothing merely interactive may use it. The accent is
also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is
a row of things that all look like the primary action.

A palette is not one file, which is the part worth knowing before the next one. Nine hex literals
lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state,
two scrims, the window background Android paints before Avalonia has a frame, and the launcher
vector. The two C# sites now resolve from the dictionary by name rather than restating it. The
renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and
terminal.js keep hand-copied values and say so at both sites.

ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over
one TransfersViewModel differing only in which picker they offer, and the kind is set by the button
that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers
force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket
already open. It refuses to change kind while a session is live, because there is one session behind
both destinations and switching under it would title a screen S3 while it listed an SFTP host.

What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here
forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub
naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd
5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a
fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet
run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are
written once at close and read when the screen opens, and the severity filter, which has nothing to
count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and
lifecycle. And the + on HOSTS, which would open a host editor this head has not got.

SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local
pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at
SpecialFolder.UserProfile, which on Android is the application's own private directory. A download
would have reported success and left the file where the person who asked for it cannot open it, which
is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is
not drawn either, since nothing here can put anything in it. Both return with the document picker.
The foreground service still counts zero transfers, and the reason moved rather than went away.

Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox
ItemTemplate swallows the pointer press before the list sees it, so the files listing selected
nothing and every command reading the selection did nothing — the row is a Border now and the
phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from
walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched
SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was
breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no
handler for the system back gesture, so back left the application from a log screen. And the tab's
close cross had shrunk to a 30x32 target flush against the select target, which is the one control
here that ends a shell with no confirmation and no undo.

Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which
is still there. Only at launch: a lock the user asked for is not answered with an immediate request
to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody
asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a
prompt that came back after being dismissed would be a modal you cannot get out of to type into it.

Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with
Accent, which was blue against green and is now two steps of one blue; an executable is Live now.
And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification
time — blank now, for the reason a directory's size is blank.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout
suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on
a device — nothing in this head ever has been; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 18:23:53 +02:00

161 lines
7.7 KiB
C#

using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
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;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Android;
/// <summary>
/// The Avalonia application, phone side.
/// </summary>
/// <remarks>
/// Named <c>DodoSshApp</c> for the same reason the desktop head's is, and then for a second reason on top
/// of it: a type called <c>App</c> in a namespace ending <c>.Android</c> is what makes every
/// <c>Android.App</c> in this assembly ambiguous. See the note at the top of MainActivity.
/// </remarks>
public sealed partial class DodoSshApp : Avalonia.Application
{
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
// ISingleViewApplicationLifetime, not IClassicDesktopStyleApplicationLifetime: a phone has one
// surface and no window to own. That difference is the whole reason the two heads cannot share a
// composition root, and very nearly the only one — everything either of them composes is the same.
if (ApplicationLifetime is ISingleViewApplicationLifetime single)
{
single.MainView = Compose();
}
base.OnFrameworkInitializationCompleted();
}
/// <remarks>
/// <para>
/// Composed by hand rather than through a container, matching the desktop head: the graph is a handful
/// of objects deep and an indirection to read through would buy nothing at this size. Read it beside
/// <c>DodoSSH.Client.App/App.axaml.cs</c> — the shape is deliberately identical, and the four
/// differences are the four things docs/android-port.md said would differ.
/// </para>
/// <para>
/// <b>Nothing here is disposed on a lifecycle hook</b>, and that is not an oversight either. Android
/// does not promise to call anything before killing a process, so a teardown path would be a comfort
/// rather than a guarantee. What actually protects the sessions is the foreground service; what
/// protects the vault keys is that they never leave memory this process owns.
/// </para>
/// </remarks>
private static PhoneShell Compose()
{
// Difference 1: the profile directory comes from the head. filesDir is per-app and non-roaming,
// which is what ClientPaths asks for and what no desktop platform guarantees.
var paths = PhoneEnvironment.Paths;
paths.EnsureCreated();
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
var knownHosts = new VaultKnownHostStore();
var connections = new SshNetConnectionFactory(knownHosts);
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
//
// Still zero transfers, and the reason moved rather than went away. v2 built the files screen, so
// this head can now browse a remote — but it cannot start a transfer, because both directions need
// the system document picker that scoped storage forces and that is not built (see FilesScreen).
// So the count is zero because the queue provably cannot have anything in it, not because nothing
// was wired. This is still the seam it arrives through: when the picker lands, this reads the
// queue and Refresh() gets called as transfers start and finish.
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
//
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
// 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,
// 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,
// 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);
return viewModel;
}
/// <summary>
/// Difference 4: where the authorization response comes back to.
/// </summary>
/// <remarks>
/// 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 OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
}