Public Access
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The phone can now put things in a keychain rather than only read one. The floating + arrives three designs after it was first asked for. It was refused twice on the honest grounds that it would open an editor this head had not got — and that stopped being true when steps 1 to 4 landed. It raises a sheet rather than a form, because "add" on this screen has been two operations since groups existed: a host, or a group to file hosts under. Both editors are cards in the list's own row, swapped for the list rather than stacked over it, following SnippetsScreen and for the reason written there: a form on top of the list hides what it is about. There is still no dialog and no editor screen anywhere on this head. The port and username boxes may be left empty, and the greyed text inside each is what the host will actually use — following the group picker as it moves, so the form says what leaving a box blank will do rather than making it a guess. The back gesture gains a guard above the switch rather than a case inside it. An editor is not a screen and has no entry there, and it is strictly nearer: the sheet sits over the editor's own screen, so back lowers whatever is topmost before it considers moving between screens. A group editor needs a way in, and the phone draws no groups panel — so the heading in the host list gains a pencil. A + that adds groups with no way to correct one is the same strange thing to ship as one that adds hosts with no way to correct one, and worse: a group's defaults are what every host beneath it falls back to. Scouting step 6 turned up a bug worth more than the prose it was looking for. ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation while a group carried only a name — a teammate's host showed under UNGROUPED and nothing else was lost. Since a group began lending a port, a username and a binding, the same omission silently dropped all three: that host would dial 22 as nobody while the machine is on 2222 as deploy, with nothing on screen saying why. The resolution map now spans every readable vault. The editable list stays narrow, because a row shown across vaults needs a vault id for rename and delete and two vaults may hold groups with one name — but the map needs none of that, since it is only ever asked what an id says. An adversarial review of this change found something I had written a comment denying. The + was a child of the outer Panel rather than of the list's row, so it anchored to the bottom of the screen and sat squarely over the new EDIT button and the lower edge of CONNECT — and because a Panel hit-tests its last-declared child first, tapping the right end of EDIT would have raised the add sheet. The comment beside it claimed it was in the list's row precisely so that could not happen, and manual-checks 8.1 names that exact failure. It is in Grid.Row 2 now. The same pass caught a connect bar that was disabled rather than hidden while its own comment said hidden, a scrim that would have flashed its default pressed chrome across the screen, a fab comment claiming an accent fill no other button had when Button.primary has it, and a back-gesture remark naming two view-model properties that do not exist anywhere in the repo. That last one came from this plan, which invented IsVaultsSurface and IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has never existed. Both are corrected in the plan rather than quietly worked around, along with a fourth "groups are flat" site in README.md that step 1 missed. The phone's rectangles are not measurable and structurally never will be: the layout suite is net10.0, the Android head is net10.0-android, and Avalonia's application is a one-shot process global — so a second head cannot share the process even if the reference were possible. Everything the sheet and both editors bind to is shared and is tested headlessly here; the pixels go to docs/manual-checks.md phase 8, nine checks, which is where this project already sends what it cannot assert. Tags remain the one thing asked for that did not ship. Tag is a full item kind and TagIds merges, encodes and resolves, but no screen draws a chip or offers to add one, so the tags a client can store are ones nothing here can see. Both editors carry the set through a save untouched so a client that can set them does not lose them. Recorded as half-shipped in docs/design-import-gaps.md rather than quietly left out. Verified by the whole suite: 1402 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display — phase 8 is what that costs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
12 KiB
C#
310 lines
12 KiB
C#
using global::Android.Views;
|
|
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Markup.Xaml;
|
|
|
|
using DodoSSH.Client.Android.Platform;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
|
|
namespace DodoSSH.Client.Android.Views;
|
|
|
|
/// <summary>The phone's single view. The desktop head's MainWindow, without the window.</summary>
|
|
internal sealed partial class PhoneShell : UserControl
|
|
{
|
|
private MainWindowViewModel? shell;
|
|
|
|
/// <summary>
|
|
/// Whether the lock screen currently showing is the one the application launched into.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Set once, when the shell arrives, because this control is built once for the process. Cleared the
|
|
/// moment the state leaves <see cref="ShellState.Locked"/>, and never set again — which is what stops a
|
|
/// deliberate lock from being answered with an immediate request to unlock. See
|
|
/// <see cref="TryOfferDeviceUnlock"/>.
|
|
/// </remarks>
|
|
private bool thisLockIsTheLaunch;
|
|
|
|
private bool offeredDeviceUnlock;
|
|
|
|
public PhoneShell()
|
|
{
|
|
AvaloniaXamlLoader.Load(this);
|
|
|
|
DataContextChanged += (_, _) =>
|
|
{
|
|
if (shell is not null)
|
|
{
|
|
shell.PropertyChanged -= OnShellChanged;
|
|
}
|
|
|
|
shell = DataContext as MainWindowViewModel;
|
|
|
|
if (shell is not null)
|
|
{
|
|
shell.PropertyChanged += OnShellChanged;
|
|
ApplyScreenshotPolicy(shell.State);
|
|
|
|
thisLockIsTheLaunch = true;
|
|
TryOfferDeviceUnlock();
|
|
}
|
|
};
|
|
}
|
|
|
|
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (shell is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
|
{
|
|
ApplyScreenshotPolicy(shell.State);
|
|
|
|
if (shell.State is not ShellState.Locked)
|
|
{
|
|
thisLockIsTheLaunch = false;
|
|
offeredDeviceUnlock = false;
|
|
}
|
|
}
|
|
|
|
// Three properties rather than one, because the condition is assembled out of order. Startup sets
|
|
// State to Locked and only then awaits the keystore to decide CanUnlockWithDevice, and it does all
|
|
// of it inside the busy wrapper — which refuses a second command outright. So whichever of the
|
|
// three settles last is the one that has to ask again.
|
|
if (e.PropertyName is nameof(MainWindowViewModel.State)
|
|
or nameof(MainWindowViewModel.CanUnlockWithDevice)
|
|
or nameof(MainWindowViewModel.IsBusy))
|
|
{
|
|
TryOfferDeviceUnlock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises the fingerprint prompt on arriving at the lock screen, rather than waiting to be asked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The button is still there and still says what it does; this only spends the tap for you. On a phone
|
|
/// that is the difference between opening the application in one gesture and in two, and the second of
|
|
/// the two was a button whose entire content was "yes, do the thing you already know I want".
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Only at launch.</b> A lock the user asked for is not answered with a request to unlock — that
|
|
/// turns LOCK into a control that appears to do nothing, and worse, trains the reflex of authenticating
|
|
/// at a prompt that appeared without being asked for. So the offer belongs to the locked screen the
|
|
/// process started on and to no other.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Once.</b> A declined gesture leaves the passphrase box exactly where it was, which is the whole
|
|
/// fallback — and a prompt that reappeared after being dismissed would be a modal the user cannot get
|
|
/// out of to type into it.
|
|
/// </para>
|
|
/// <para>
|
|
/// Nothing here needs a failure path. <c>UnlockWithDeviceAsync</c> turns every refusal into a status
|
|
/// line, and a phone with no enrolled fingerprint never gets here at all, because
|
|
/// <c>CanUnlockWithDevice</c> already asked the keystore.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void TryOfferDeviceUnlock()
|
|
{
|
|
if (!thisLockIsTheLaunch || offeredDeviceUnlock)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (shell is not { State: ShellState.Locked, CanUnlockWithDevice: true, IsBusy: false } current)
|
|
{
|
|
return;
|
|
}
|
|
|
|
offeredDeviceUnlock = true;
|
|
current.UnlockWithDeviceCommand.Execute(null);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
|
{
|
|
base.OnAttachedToVisualTree(e);
|
|
|
|
if (TopLevel.GetTopLevel(this) is { } top)
|
|
{
|
|
top.BackRequested += OnBackRequested;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
|
{
|
|
if (TopLevel.GetTopLevel(this) is { } top)
|
|
{
|
|
top.BackRequested -= OnBackRequested;
|
|
}
|
|
|
|
base.OnDetachedFromVisualTree(e);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes the system back gesture up the hierarchy rather than out of the application.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// v2 is the first arrangement here with a second level: five destinations sit behind MORE, each with
|
|
/// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first,
|
|
/// and left unhandled it does not go up — it finishes the activity. Ending the application from a log
|
|
/// screen is not a plausible reading of "back".
|
|
/// </para>
|
|
/// <para>
|
|
/// Handled in the order the interface is stacked, not by screen alone: the terminal is a surface over a
|
|
/// page, so it is dismissed before the page under it is considered. Setting <c>Handled</c> is what stops
|
|
/// the event being re-dispatched to the activity's own default.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Two things it deliberately does not do.</b> It does not answer a host-key prompt — those are
|
|
/// decisions with two named buttons, and a gesture that dismissed one would be the swipe-to-dismiss this
|
|
/// head refused when it made the changed-key refusal a full-screen panel rather than a sheet. And from
|
|
/// the host list it does nothing at all, so back still leaves the application from the screen the
|
|
/// application opens on, which is what every other Android app does.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's first case
|
|
/// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> itself,
|
|
/// and has to stay in step with it — a screen added to the hub and not to that case would trap the user
|
|
/// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet
|
|
/// sits over the host editor's own screen, so back has to lower whatever is topmost before it considers
|
|
/// moving between screens at all. Closing an editor is not the same refusal as leaving a host-key
|
|
/// decision alone — an editor is abandonable by design, and the CANCEL button beside it says so.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnBackRequested(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (shell is not { State: ShellState.Unlocked } current)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// A decision is on screen. Leave it alone — see the remark.
|
|
if (current.Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true }
|
|
|| current.Transfers is { HasPendingHostKey: true } or { HasHostKeyMismatch: true })
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (TryCloseAnOpenEditor(current))
|
|
{
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
if (!current.IsShowingPages)
|
|
{
|
|
current.ShowScreenCommand.Execute(current.Screen);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
switch (current.Screen)
|
|
{
|
|
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
|
|
or ShellScreen.Buckets or ShellScreen.Preferences:
|
|
current.ShowScreenCommand.Execute(ShellScreen.More);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
case ShellScreen.More or ShellScreen.Vault:
|
|
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
|
|
e.Handled = true;
|
|
break;
|
|
|
|
default:
|
|
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
|
/// </summary>
|
|
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Order is the whole of it. The add sheet sits over the list and the two editors sit in place of it, so
|
|
/// the sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
|
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
|
/// editor the user was looking at.
|
|
/// </para>
|
|
/// <para>
|
|
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
|
|
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
|
|
/// next NEW HOST open on somebody else's abandoned draft.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
|
{
|
|
if (current.Vault is not { } vault)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (vault.IsAddSheetOpen)
|
|
{
|
|
vault.CloseAddSheetCommand.Execute(null);
|
|
return true;
|
|
}
|
|
|
|
if (vault.IsEditing)
|
|
{
|
|
vault.CancelEditCommand.Execute(null);
|
|
return true;
|
|
}
|
|
|
|
if (vault.IsEditingGroup)
|
|
{
|
|
vault.CancelGroupEditCommand.Execute(null);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Blocks screenshots and screen recording while the recovery code is on screen.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The recovery screen says screenshots are blocked, and this is what makes that true rather than a
|
|
/// claim. <c>FLAG_SECURE</c> is a window flag — no control can set it — so it lives here, on the one
|
|
/// object that has the activity.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It is worth being clear about what this buys.</b> It stops the obvious accident — a screenshot
|
|
/// of the only copy of an unrecoverable code landing in a cloud photo library — and it excludes the
|
|
/// screen from the recent-apps thumbnail, which is the part users never think about. It stops nothing
|
|
/// determined: a second phone photographs the screen perfectly well. The code is meant to be written
|
|
/// down, and this only pushes people away from the one place it must not be written down to.
|
|
/// </para>
|
|
/// <para>
|
|
/// Lowered again afterwards rather than left on. Leaving it set would make the terminal unscreenshotable
|
|
/// too, and a screenshot of a shell is a thing people legitimately want.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void ApplyScreenshotPolicy(ShellState state)
|
|
{
|
|
if (PhoneEnvironment.CurrentActivity?.Window is not { } window)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (state is ShellState.ShowingRecoveryCode)
|
|
{
|
|
window.SetFlags(WindowManagerFlags.Secure, WindowManagerFlags.Secure);
|
|
}
|
|
else
|
|
{
|
|
window.ClearFlags(WindowManagerFlags.Secure);
|
|
}
|
|
}
|
|
}
|