diff --git a/docs/android-port.md b/docs/android-port.md
index c3795c8..e07119a 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -562,3 +562,16 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
confirmed to exist before anything is written to solve it.
+- **The software keyboard is kept off the interface in one place, and by two mechanisms.** `PhoneShell`
+ owns it rather than each screen, because everything the phone draws is inside that one control and a
+ screen added later would otherwise have to remember. The two mechanisms are not a belt and braces: before
+ Android 15 the activity's `AdjustResize` has the platform shorten the window and the reported keyboard
+ inset arrives already consumed, and from Android 15 edge-to-edge is enforced, the window is no longer
+ resized for the keyboard, and the inset is what there is. Each is dead where the other applies, which is
+ why the margin is taken from the inset alone — the two added together would strand the interface an
+ entire keyboard too high. See `docs/manual-checks.md` phase 10, which is the only way either is verified.
+- **A box that takes a secret says so twice.** `PasswordChar` is what the screen draws and
+ `TextInputOptions.ContentType` is what the software keyboard is told, and only the second one turns off
+ the suggestion strip and keeps the passphrase out of the IME's learning dictionary. The desktop head
+ needs only the first, which is why the phone's `TextBox.secret` class carries both rather than the two
+ being set per box.
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 53bcd9e..0acef2e 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -834,3 +834,58 @@ nothing else about them changes. Neither host is queued for push.
On the phone's host editor with several tags: the chips are at least 36 tall, spaced enough that a miss
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
being squeezed to nothing.
+
+---
+
+## Phase 10 — The software keyboard and the boxes that take secrets
+
+Every check here needs a real Android device or emulator, and there is no headless equivalent of any of
+them: the software keyboard is an inset the platform reports, and a headless top level reports none.
+Worth running on two devices if you have them — one on Android 14 or earlier and one on Android 15 or
+later — because the interface is kept clear of the keyboard by a different mechanism on each. Before 15 the
+activity's `AdjustResize` has the platform shorten the window; from 15 the window is not resized at all and
+`PhoneShell` applies the reported inset itself. A build that only ever ran on one of the two will look
+correct and be half broken.
+
+### 10.1 The vault passphrase box is treated as a password by the keyboard
+
+Launch to the lock screen, tap the passphrase box, type a few characters.
+
+**Pass:** dots on screen, and **no suggestion strip above the keyboard** — no completions, no previously
+typed words, no autocorrect. Then open any ordinary box on the phone (the host search, a snippet's name)
+and confirm the suggestions come back there.
+
+**Failure means:** `TextInputOptions.ContentType` is missing — most likely a box was given `PasswordChar`
+directly instead of `Classes="... secret"`. `PasswordChar` is what the screen draws; the content type is
+what the keyboard is told, and only the second one keeps a passphrase out of the IME's learning
+dictionary. A box showing dots with a suggestion strip over it is the worst case, not a cosmetic one.
+
+### 10.2 The keyboard does not cover the box being typed into
+
+The same box: with the keyboard up, the passphrase box and the UNLOCK button under it are both visible.
+Repeat on each of the five boxes that take a secret — lock screen, both enrollment boxes, the connect
+password on HOSTS, and the connect password on FILES.
+
+**Pass:** the box stays on screen when the keyboard opens, and the interface is shortened rather than slid
+— the header stays where it is rather than scrolling off the top.
+
+**Failure means:** on Android 15 or later, the inset is no longer reaching `PhoneShell`. On 14 or earlier,
+`WindowSoftInputMode` has been dropped from the activity and the platform is panning the window instead of
+resizing it — which, for a window whose whole content is one native view, pans by nothing useful.
+
+### 10.3 Nothing is stranded when the keyboard closes
+
+Dismiss the keyboard with back or the down-chevron from each of those screens.
+
+**Pass:** the interface fills the screen again immediately, with no band of empty canvas left along the
+bottom and no scroll position left part way down.
+
+**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
+event, or the margin is only ever added to.
+
+### 10.4 Rotating with the keyboard up
+
+Focus a passphrase box, then turn the phone sideways.
+
+**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
+rotation rather than being recreated, and live shells survive it.
diff --git a/src/DodoSSH.Client.Android/MainActivity.cs b/src/DodoSSH.Client.Android/MainActivity.cs
index a61b308..02cbcbb 100644
--- a/src/DodoSSH.Client.Android/MainActivity.cs
+++ b/src/DodoSSH.Client.Android/MainActivity.cs
@@ -8,6 +8,7 @@ using DodoSSH.Client.Android.Platform;
using global::Android.App;
using global::Android.Content;
using global::Android.Content.PM;
+using global::Android.Views;
namespace DodoSSH.Client.Android;
@@ -34,12 +35,26 @@ namespace DodoSSH.Client.Android;
/// other launch mode answers it with a second copy of this activity on top of the first — which on this
/// head would mean a second Avalonia application over a live one.
///
+///
+/// AdjustResize is declared rather than left unspecified, and it is half of how this head
+/// keeps the software keyboard off the box being typed into; PhoneShell is the other half. Left
+/// unspecified, Android chooses, and what it chooses for a window whose entire content is one native view
+/// — which is what an Avalonia surface is — is to pan: it slides the window up by however much it thinks
+/// the focused *native* view needs, and since that view is the whole surface, the passphrase box goes on
+/// sitting under the keyboard. Resizing instead makes the window shorter, which the layout inside it can
+/// answer, and a screen built around a ScrollViewer then scrolls the focused box into view by
+/// itself. On Android 15 and later this attribute is ignored — edge-to-edge is enforced there and the
+/// window is no longer resized for the keyboard — which is precisely the case PhoneShell handles from the
+/// reported inset. The two are complementary and never both in effect: where the window resizes, the
+/// keyboard inset arrives already consumed and measures zero.
+///
///
[Activity(
Label = "DodoSSH",
Theme = "@style/DodoTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTask,
+ WindowSoftInputMode = SoftInput.AdjustResize,
ConfigurationChanges = ConfigChanges.Orientation
| ConfigChanges.ScreenSize
| ConfigChanges.ScreenLayout
diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml
index d27fcd7..8be44bb 100644
--- a/src/DodoSSH.Client.Android/Theme/Phone.axaml
+++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml
@@ -320,6 +320,27 @@
+
+
+
-
+
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
index fcff1cb..c1fcbe6 100644
--- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
@@ -419,8 +419,8 @@
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
-->
-
+
diff --git a/src/DodoSSH.Client.Android/Views/LockedScreen.axaml b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml
index c4d6b5a..1d9e6ef 100644
--- a/src/DodoSSH.Client.Android/Views/LockedScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/LockedScreen.axaml
@@ -49,7 +49,7 @@
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
phone client called slow.
-->
-
-
+
+ The software keyboard, while this control is attached. Null on a platform without one.
+ private IInputPane? keyboard;
+
///
/// Whether the lock screen currently showing is the one the application launched into.
///
@@ -32,6 +37,11 @@ internal sealed partial class PhoneShell : UserControl
{
AvaloniaXamlLoader.Load(this);
+ // Subscribed once, for the life of the control, rather than in OnAttachedToVisualTree: Body is this
+ // control's own child and cannot outlive it, and re-subscribing on every attach is how a handler
+ // ends up registered twice.
+ Body.SizeChanged += OnBodyResized;
+
DataContextChanged += (_, _) =>
{
if (shell is not null)
@@ -132,6 +142,14 @@ internal sealed partial class PhoneShell : UserControl
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested += OnBackRequested;
+
+ keyboard = top.InputPane;
+
+ if (keyboard is not null)
+ {
+ keyboard.StateChanged += OnKeyboardChanged;
+ ApplyKeyboardInset(keyboard);
+ }
}
}
@@ -143,9 +161,99 @@ internal sealed partial class PhoneShell : UserControl
top.BackRequested -= OnBackRequested;
}
+ if (keyboard is not null)
+ {
+ keyboard.StateChanged -= OnKeyboardChanged;
+ keyboard = null;
+ }
+
base.OnDetachedFromVisualTree(e);
}
+ private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
+ => ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
+
+ private void ApplyKeyboardInset(IInputPane pane)
+ => ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0);
+
+ ///
+ /// Holds the phone's whole interface clear of the software keyboard.
+ ///
+ ///
+ ///
+ /// Here rather than on each screen, because the keyboard is not a screen's business. Five of them
+ /// have a box that can be typed into and every one of them would need the same handler; a sixth added
+ /// later would silently not have it. Everything the phone draws is inside Body, so one bottom
+ /// margin shortens all of them at once — which is the same thing the window resizing would have done,
+ /// and is why the two paths below never both apply.
+ ///
+ ///
+ /// Two paths, one of which is dead on any given device. Before Android 15, the activity's
+ /// AdjustResize makes the platform shorten the window itself and the keyboard inset reaches
+ /// Avalonia already consumed — this measures zero and the margin stays where it is. From Android 15 the
+ /// window is no longer resized for the keyboard at all, edge-to-edge being enforced, and the inset is
+ /// reported instead: that is the number applied here. Adding a margin on top of a window that had
+ /// already shrunk would strand the interface an entire keyboard above the keyboard, which is why the
+ /// value is taken from the inset alone and never from both.
+ ///
+ ///
+ /// Scrolling the box back into view is deliberately not done here. ScrollViewer already brings a
+ /// newly focused child into view, and every screen with a box on it is inside one; what it cannot know
+ /// is that the visible region shrank *after* the focus. So the trigger is the resize this margin causes
+ /// — see — and not this method, which would run a layout pass too early to
+ /// have anything to scroll to.
+ ///
+ ///
+ private void ApplyKeyboardInset(double occluded)
+ {
+ var inset = double.IsFinite(occluded) ? Math.Max(occluded, 0) : 0;
+
+ if (Math.Abs(Body.Margin.Bottom - inset) > 0.5)
+ {
+ Body.Margin = new Thickness(0, 0, 0, inset);
+ }
+ }
+
+ ///
+ /// Scrolls whatever has the keyboard back into view once the room left for it is known.
+ ///
+ ///
+ ///
+ /// The one moment this is needed is the one no other handler sees: the box was focused while the whole
+ /// screen was available, and the space it sits in shrank afterwards. Both ways of losing that space end
+ /// here — the margin applied above, and the platform shortening the window on Android 14 and earlier —
+ /// which is why the resize is the trigger rather than either of the two things that cause it.
+ ///
+ ///
+ /// Posted rather than called, and at Loaded priority, because the size change is raised during
+ /// the layout pass that caused it: asking a ScrollViewer to scroll to a child whose new bounds
+ /// have not been written yet scrolls to where the child used to be.
+ ///
+ ///
+ /// Only while the keyboard is up. Every rotation and every screen change resizes this control too, and
+ /// a shell that scrolled to the focused control on each of them would be a shell that moves under you.
+ ///
+ ///
+ private void OnBodyResized(object? sender, SizeChangedEventArgs e)
+ {
+ if (keyboard is not { State: InputPaneState.Open })
+ {
+ return;
+ }
+
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ // Whatever holds focus, not the passphrase box by name: this runs for eleven screens and
+ // the one the keyboard is up for is the only one that can answer which box that is.
+ if (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is Control focused)
+ {
+ focused.BringIntoView();
+ }
+ },
+ DispatcherPriority.Loaded);
+ }
+
///
/// Takes the system back gesture up the hierarchy rather than out of the application.
///