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; /// The phone's single view. The desktop head's MainWindow, without the window. 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); 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(); } } /// /// 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. /// /// /// v3 adds one guard above the switch rather than another case inside it. The switch's first case /// is the membership test of minus More 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. /// /// 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; } } /// /// Lowers whatever the hosts screen has raised over its list, topmost first. /// /// Whether anything was closed, and so whether back has been spent. /// /// /// 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. /// /// /// 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. /// /// 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; } /// /// Blocks screenshots and screen recording while the recovery code is on screen. /// /// /// /// The recovery screen says screenshots are blocked, and this is what makes that true rather than a /// claim. FLAG_SECURE is a window flag — no control can set it — so it lives here, on the one /// object that has the activity. /// /// /// It is worth being clear about what this buys. 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. /// /// /// 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. /// /// 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); } } }