diff --git a/README.md b/README.md index b33a7c2..e84efe9 100644 --- a/README.md +++ b/README.md @@ -268,10 +268,21 @@ the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read t host-key decisions and the counted delete confirmations are there too, and none of them were softened to fit 360dp. -What it does **not** have is file transfer — deliberately, since scoped storage means there is no local -pane to put beside the remote one — and the four list screens the desktop grew last (pins, snippets, logs, -teams), whose view models are already shared and which are additive rather than structural. Importing an -`~/.ssh/config` has no meaning on a phone at all. +Its interface is the **v2 design**: four destinations in a bottom bar — Hosts, Terminal, Vault and More — +with snippets, SFTP, S3 buckets, logs and preferences one tap deeper behind the last. That is also where +the product's palette now comes from, and both heads share it, so the desktop is blue too. + +File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over +either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to +put in one — moving a file *in* from the phone needs the system document picker and is the next piece of +work rather than a thing the screen pretends to do. What is still absent is a host editor and a keychain +item editor, so hosts and keys are created on the desktop and sync down; pins, teams and import have no +phone screen either. Importing an `~/.ssh/config` has no meaning on a phone at all. + +**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than +leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so +every control on that screen would have had no effect. See +[`docs/design-import-gaps.md`](docs/design-import-gaps.md). It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which diff --git a/docs/android-port.md b/docs/android-port.md index 62bdfa3..f72624b 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -439,6 +439,15 @@ go at 360dp: `setInvalidatedByBiometricEnrollment` is on, which is what stops somebody who can add a fingerprint to an unlocked phone from inheriting the vault — the cost being that re-enrolment permanently destroys the key, which the load path treats as ordinary rather than exceptional. + + **The prompt is raised on arrival at the lock screen** rather than waiting for the button, which is still + there and still says what it does. Two rules shape it, both in `PhoneShell.TryOfferDeviceUnlock`. It + happens **only at launch**: a lock the user asked for is not answered with a request to unlock, because + that makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And it + happens **once**: a declined gesture leaves the passphrase box exactly where it was, which is the whole + fallback, and a prompt that came back after being dismissed would be a modal you cannot get out of to + type into it. It watches three properties rather than one because startup sets the state to `Locked` + before it has asked the keystore whether there is a key to offer, and does both inside the busy wrapper. 5. ~~**Android sign-in.**~~ **Done**, and the seam it needed turned out to be 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, @@ -477,6 +486,14 @@ go at 360dp: The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here: import has no meaning under scoped storage, and the other four are list screens whose view models already exist — they are additive rather than structural. + + **Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took + the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the + view models that were already shared, behind a MORE hub that holds the five destinations the bottom bar + has no room for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved + together, because it is shared. What v2 asked for and did not get is in + [`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is + named as absent rather than drawn, because nothing in the SSH layer forwards anything. 8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never reflows a terminal while output is arriving. @@ -487,9 +504,19 @@ go at 360dp: drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt believing they typed an `l`. -All eight are done for the decided scope. What is left is not on this list: the four destinations the -phone does not draw, file transfer (deliberately deferred, and see the decision above for the shape it -takes), the App Link upgrade, and — the one that is not optional — running any of it on a device. +All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7. + +What is left, in the order it matters: + +- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has + ever been launched on hardware or an emulator. Everything below is reasoning from the code. +- **The document picker.** The files screen browses a remote, downloads from it and runs the queue; putting + a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped + source, which is the decision recorded above and the obvious next piece of work. +- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on- + desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn. +- **Pins, teams and import**, which v2 does not draw either. +- **The App Link upgrade**, unchanged from step 5. --- diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 18739dd..20c9161 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -3,6 +3,41 @@ The Avalonia client's interface was rebuilt from a Claude Design project — a five-screen spec covering the chrome, hosts and terminals, file transfer, the vault, teams, and preferences. +> ## The second import — v2, phone +> +> A later design in the same project, **DodoSSH Android v2**, is what the phone head now draws: eight +> screens, a bottom bar of four with the rest behind MORE, and a new palette. It has been imported, and +> this section is the same accounting for it. The desktop's own v2 has **not** been imported — only its +> palette, because the palette is shared and the two heads may not disagree about a colour. +> +> **The palette went from green to blue, and one colour changed meaning rather than value.** Green used to +> be the accent, so `Ellipse.dot.live` filled with `Accent` and "the thing to press" and "a shell is open +> here" were the same colour by construction. v2 makes the accent `#5B8CFF` and keeps a green, `#3DDC97`, +> for status alone. That is now `Live`, and nothing merely interactive may use it. The accent is also two +> colours — `Accent` fills, `AccentText` writes — which is what stops a row of chips reading as a row of +> primary actions. +> +> **Five destinations moved under MORE and four of them were built**: snippets, SFTP, S3 and logs, each +> over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`; +> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer. +> +> | v2 element | What ships instead | +> | --- | --- | +> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. | +> | `23 ms · fwd 5432` on the terminal's connection line | The account and endpoint actually dialled. There is no RTT measurement in SSH.NET and nothing forwards anything. | +> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. | +> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. | +> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. | +> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. | +> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. | +> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. | +> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against 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 report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. | +> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. | +> | A `+` floating button on **HOSTS** | Omitted. It would open a host editor, and this head has none — hosts are created on the desktop and sync down. | +> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. | +> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. | +> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. | + Most of it landed. This file is the rest: every element of that design with nothing behind it, which project each piece would have to land in, and **what the shipped interface does instead**. That last column is the important one. This codebase states its limits rather than implying them — the README does diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index 6f5bc1a..f0f54fa 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -76,8 +76,13 @@ public sealed partial class DodoSshApp : Avalonia.Application // 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. - // Zero transfers for now: file transfer is out of this head's first scope by decision, and this is - // the seam it arrives through rather than a dependency taken before there is anything behind it. + // + // 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. diff --git a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml index b69c3f7..3978443 100644 --- a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml +++ b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml @@ -5,7 +5,7 @@ bitmap so there is one geometry to change and no set of five PNG densities to forget one of. - #3CE88F is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out + #5B8CFF is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an Android resource cannot reference a XAML dictionary. The same duplication colors.xml already carries for the window background, and the same rule applies: if the palette moves, this moves with it. @@ -31,14 +31,14 @@ @@ -47,7 +47,7 @@ diff --git a/src/DodoSSH.Client.Android/Resources/values/colors.xml b/src/DodoSSH.Client.Android/Resources/values/colors.xml index 9c62f67..d6dc3c9 100644 --- a/src/DodoSSH.Client.Android/Resources/values/colors.xml +++ b/src/DodoSSH.Client.Android/Resources/values/colors.xml @@ -9,5 +9,5 @@ between an Android resource and a XAML resource dictionary, so the duplication is stated rather than hidden. --> - #0A0C0B + #0E1220 diff --git a/src/DodoSSH.Client.Android/Resources/values/styles.xml b/src/DodoSSH.Client.Android/Resources/values/styles.xml index f2b81b0..6832c2d 100644 --- a/src/DodoSSH.Client.Android/Resources/values/styles.xml +++ b/src/DodoSSH.Client.Android/Resources/values/styles.xml @@ -12,7 +12,7 @@ the sync dot, which a system action bar has nowhere to put. windowLightStatusBar is false so the clock and the battery icon are drawn light. The design's status - strip is #7E8A84 text on near-black, and leaving this at its default renders dark-on-dark — legible + strip is #8B93B0 text on near-black, and leaving this at its default renders dark-on-dark — legible on the mock-up and invisible on a device. --> @@ -48,7 +54,7 @@ - + @@ -63,7 +69,7 @@ - + @@ -76,7 +82,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + @@ -177,11 +282,49 @@ + + + + + + + + diff --git a/src/DodoSSH.Client.Android/Views/FilesScreen.axaml b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml new file mode 100644 index 0000000..210e87d --- /dev/null +++ b/src/DodoSSH.Client.Android/Views/FilesScreen.axaml @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Android/Views/MoreScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml.cs new file mode 100644 index 0000000..443b1fc --- /dev/null +++ b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml.cs @@ -0,0 +1,10 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace DodoSSH.Client.Android.Views; + +/// Design v2 — MORE, the hub for the five destinations the bottom bar has no room for. +internal sealed partial class MoreScreen : UserControl +{ + public MoreScreen() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/DodoSSH.Client.Android/Views/NavButton.cs b/src/DodoSSH.Client.Android/Views/NavButton.cs index d6a936d..9dda918 100644 --- a/src/DodoSSH.Client.Android/Views/NavButton.cs +++ b/src/DodoSSH.Client.Android/Views/NavButton.cs @@ -17,13 +17,21 @@ namespace DodoSSH.Client.Android.Views; /// /// /// -/// Written in C# rather than as a XAML control because it is a button with a label and a rule about the -/// accent bar, and a templated control for that would be more machinery than the thing it draws. +/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about +/// the accent bar, and a templated control for that would be more machinery than the thing it draws. /// /// -/// The accent bar is on top, not underneath. The design puts it there in the nav rail and it is -/// worth keeping on a phone for a reason the desktop does not have: the bottom of a phone screen is under -/// the system's own gesture bar, and an indicator drawn there is one the user cannot see. +/// The accent bar is on top, not underneath. The v2 design distinguishes the current destination with +/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the +/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar, +/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is +/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are +/// three, which is the right number for the control that says where you are. +/// +/// +/// Every colour here is resolved from Theme/Palette.axaml rather than written out. A nav bar that +/// named its own blues is exactly how the fourth place a colour appears ends up one shade off — which is the +/// argument the desktop head's Ellipse.dot style makes for being a style rather than a converter. /// /// internal sealed class NavButton : Button @@ -31,12 +39,26 @@ internal sealed class NavButton : Button public static readonly StyledProperty LabelProperty = AvaloniaProperty.Register(nameof(Label), string.Empty); + /// The glyph drawn above the label. + /// + /// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts + /// Android already has, so shipping four vectors to draw them would buy nothing but four more files to + /// keep in step with the palette. + /// + public static readonly StyledProperty GlyphProperty = + AvaloniaProperty.Register(nameof(Glyph), string.Empty); + public static readonly StyledProperty IsCurrentProperty = AvaloniaProperty.Register(nameof(IsCurrent)); private readonly Border indicator; + private readonly TextBlock glyph; private readonly TextBlock text; + // Resolved from the palette once the control is in the tree; see Repaint. + private IBrush? currentBrush; + private IBrush? idleBrush; + public NavButton() { Background = Brushes.Transparent; @@ -55,16 +77,28 @@ internal sealed class NavButton : Button Background = Brushes.Transparent, }; + glyph = new TextBlock + { + FontSize = 15, + HorizontalAlignment = HorizontalAlignment.Center, + }; + text = new TextBlock { FontSize = 9, - FontWeight = FontWeight.SemiBold, + FontWeight = FontWeight.Medium, HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - Foreground = new SolidColorBrush(Color.Parse("#566059")), }; - Content = new Panel { Children = { indicator, text } }; + var stack = new StackPanel + { + Spacing = 3, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { glyph, text }, + }; + + Content = new Panel { Children = { indicator, stack } }; } public string Label @@ -73,6 +107,13 @@ internal sealed class NavButton : Button set => SetValue(LabelProperty, value); } + /// + public string Glyph + { + get => GetValue(GlyphProperty); + set => SetValue(GlyphProperty, value); + } + /// Whether this is the destination currently showing. public bool IsCurrent { @@ -89,14 +130,13 @@ internal sealed class NavButton : Button { text.Text = Label; } + else if (change.Property == GlyphProperty) + { + glyph.Text = Glyph; + } else if (change.Property == IsCurrentProperty) { - // Both the label and the bar, not just the bar. Colour alone at 9pt on a dark background is a - // weak signal, and it is the only one a user with a colour deficiency would have. - text.Foreground = new SolidColorBrush(Color.Parse(IsCurrent ? "#3CE88F" : "#566059")); - indicator.Background = IsCurrent - ? new SolidColorBrush(Color.Parse("#3CE88F")) - : Brushes.Transparent; + Repaint(); } else if (change.Property == FontFamilyProperty) { @@ -115,5 +155,27 @@ internal sealed class NavButton : Button { text.FontFamily = mono; } + + currentBrush = Application.Current?.FindResource("AccentText") as IBrush; + idleBrush = Application.Current?.FindResource("TextFaint") as IBrush; + + // IsCurrent is normally bound and so has usually already been set by the time the template applies, + // which is why this runs again here rather than only on the property change. + Repaint(); + } + + private void Repaint() + { + // Both the label and the bar, not just the bar — and the weight as well. See the remark on the type. + var paint = IsCurrent ? currentBrush : idleBrush; + + if (paint is not null) + { + glyph.Foreground = paint; + text.Foreground = paint; + } + + text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium; + indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent; } } diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml index 3e23d2e..87d09fd 100644 --- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml +++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml @@ -13,10 +13,14 @@ It switches on ShellState, which is the same six states the desktop has because both heads share one state machine. What differs is only what each one draws. - The nav rail's eight destinations become four in a bottom bar. That is not a reflow: the plan says five - characters was a desktop constraint and the phone can use words, and it also says a phone cannot carry - eight. What is here is what the decided scope needs — the keychain and a terminal — and the destinations - behind the other four are reachable from those two rather than being hidden. + ── v2 ──────────────────────────────────────────────────────────────────────────────────────────────── + The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper + behind MORE: snippets, SFTP, S3, logs and preferences. That is the v2 design's own arrangement, and it + replaces the first design's four, which had nothing behind them at all. Five characters was a desktop + constraint and the phone uses words. + + The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two + are the pair a session moves between; on the desktop the terminal is not a rail entry at all. --> @@ -34,29 +38,47 @@ - + - - - + + + + + - - - - - - + + + + + + - @@ -85,15 +107,60 @@ --> - + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + BorderThickness="0,1,0,0" Height="64"> - - - - - + + CommandParameter="{x:Static vm:ShellScreen.Vault}" /> + + + diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs index 3c4a1b7..9eccfe7 100644 --- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs +++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs @@ -1,6 +1,8 @@ using global::Android.Views; +using Avalonia; using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using DodoSSH.Client.Android.Platform; @@ -13,6 +15,19 @@ internal sealed partial class PhoneShell : UserControl { private MainWindowViewModel? shell; + /// + /// Whether the lock screen currently showing is the one the application launched into. + /// + /// + /// Set once, when the shell arrives, because this control is built once for the process. Cleared the + /// moment the state leaves , and never set again — which is what stops a + /// deliberate lock from being answered with an immediate request to unlock. See + /// . + /// + private bool thisLockIsTheLaunch; + + private bool offeredDeviceUnlock; + public PhoneShell() { AvaloniaXamlLoader.Load(this); @@ -30,15 +45,167 @@ internal sealed partial class PhoneShell : UserControl { shell.PropertyChanged += OnShellChanged; ApplyScreenshotPolicy(shell.State); + + thisLockIsTheLaunch = true; + TryOfferDeviceUnlock(); } }; } private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { - if (shell is not null && e.PropertyName is nameof(MainWindowViewModel.State)) + 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(); + } + } + + /// + /// Raises the fingerprint prompt on arriving at the lock screen, rather than waiting to be asked. + /// + /// + /// + /// 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". + /// + /// + /// Only at launch. 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. + /// + /// + /// Once. 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. + /// + /// + /// Nothing here needs a failure path. UnlockWithDeviceAsync turns every refusal into a status + /// line, and a phone with no enrolled fingerprint never gets here at all, because + /// CanUnlockWithDevice already asked the keystore. + /// + /// + 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); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (TopLevel.GetTopLevel(this) is { } top) + { + top.BackRequested += OnBackRequested; + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (TopLevel.GetTopLevel(this) is { } top) + { + top.BackRequested -= OnBackRequested; + } + + base.OnDetachedFromVisualTree(e); + } + + /// + /// Takes the system back gesture up the hierarchy rather than out of the application. + /// + /// + /// + /// 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". + /// + /// + /// 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 Handled is what stops + /// the event being re-dispatched to the activity's own default. + /// + /// + /// Two things it deliberately does not do. 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. + /// + /// + 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 (!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; } } diff --git a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml new file mode 100644 index 0000000..5f433fe --- /dev/null +++ b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml @@ -0,0 +1,167 @@ + + + + + + + + + - - - - - - + + + + + @@ -70,11 +88,16 @@ A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head has no keyboard to press it with, which is why the two heads differ here and nowhere else in this screen. --> - + - @@ -149,11 +178,10 @@ then release, because holding a modifier while typing is not possible one-thumbed. --> - + - + diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs index 40b562d..bbeeeec 100644 --- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs +++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs @@ -129,13 +129,13 @@ internal sealed partial class TerminalScreen : UserControl // 44 wide as well as tall. The design draws them flexed across the width, which at 360dp // with ten keys is 32 pixels each — under every thumb-target guideline there is. MinWidth = 44, - Height = 34, + Height = 38, Padding = new Thickness(10, 0), - CornerRadius = new CornerRadius(5), - Background = new SolidColorBrush(Color.Parse("#161B19")), - BorderBrush = new SolidColorBrush(Color.Parse("#232927")), + CornerRadius = new CornerRadius(9), + Background = Palette("Panel"), + BorderBrush = Palette("BorderMid"), BorderThickness = new Thickness(1), - Foreground = new SolidColorBrush(Color.Parse("#B7C0BB")), + Foreground = Palette("TextDim"), HorizontalContentAlignment = HorizontalAlignment.Center, }; @@ -164,13 +164,18 @@ internal sealed partial class TerminalScreen : UserControl // Latched state has to be visible. A modifier that is on and does not look on is how somebody sends // ^L to a database prompt believing they typed an l. - controlKey.Background = new SolidColorBrush( - Color.Parse(controlLatched ? "#243A2F" : "#161B19")); - - controlKey.Foreground = new SolidColorBrush( - Color.Parse(controlLatched ? "#3CE88F" : "#B7C0BB")); + controlKey.Background = Palette(controlLatched ? "Active" : "Panel"); + controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim"); } + /// One brush from Theme/Palette.axaml, by key. + /// + /// The accessory row is built in code because its keys come from a table, so its colours cannot be set in + /// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named + /// its own blues is how the palette ends up with a fifth surface nobody meant to add. + /// + private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush; + /// /// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to /// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a diff --git a/src/DodoSSH.Client.App/App.axaml b/src/DodoSSH.Client.App/App.axaml index 985b678..fe071d5 100644 --- a/src/DodoSSH.Client.App/App.axaml +++ b/src/DodoSSH.Client.App/App.axaml @@ -428,8 +428,12 @@ decoration that reads as information. A style rather than a value converter so that the two colours are the palette's, once. A converter - would have had to name #3CE88F in C#, which is how a green ends up one shade off in the fourth place + would have had to name #3DDC97 in C#, which is how a green ends up one shade off in the fourth place it appears. + + Live rather than Accent, and that is a v2 change with meaning rather than a recolour. The accent is + blue now, and the green survives as the colour of a fact — see the remark on Live in Palette.axaml. + Filling this with Accent would say a connected host is a thing to press. --> - + + diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml index 847ba66..47a068c 100644 --- a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml +++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml @@ -37,11 +37,21 @@ + - #0A0C0B + + + #0E1220 - - - - - + + + + + + + + - - + + + + + + + + - - - - - + + + + + - - - - + + + + - #3CE88F + #5B8CFF - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +