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);
}
}
}