diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 1d07b81..55027a2 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -1750,15 +1750,18 @@ tap of an arrow key.
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
keep typing on the software keyboard.
-**Pass:** the keyboard does not change — not its layout, not its suggestion strip, not its height — and
+**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
-keyboard throughout.
+keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
+halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
+for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
-handed back. This is the half `Focusable = false` cannot reach — the platform moves its focus on the touch
-itself, before Avalonia decides anything — and the symptom chain is the keyboard swapping to its no-input
-layout and the inset churn parking it over the very row that was tapped. See
-`TerminalFocus` in the Android head's Platform folder.
+handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
+view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
+layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
+timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
+`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
### 11.11 Closing a connection and opening a new one both take you somewhere real
diff --git a/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs b/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs
index a2d4617..97b929e 100644
--- a/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs
+++ b/src/DodoSSH.Client.Android/Platform/TerminalFocus.cs
@@ -10,17 +10,24 @@ namespace DodoSSH.Client.Android.Platform;
/// The sibling of , and it exists for the same reason that one does: the
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
/// not own. The accessory row's keys are already Focusable=false — see TerminalScreen — so Avalonia's
-/// idea of focus never leaves the terminal when one is tapped. What still moves is Android's: the
-/// tap lands on Avalonia's own input view, which is focusable-in-touch-mode because Avalonia's text boxes
-/// need it to be, and the platform hands that view focus on the way to delivering the touch. The WebView's
-/// input connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no
-/// text, and the inset churn that follows can leave it sitting on top of the very row that was tapped.
+/// idea of focus never leaves the terminal when one is tapped. What still moves is Android's:
+/// AvaloniaView.DispatchTouchEvent (decompiled from Avalonia.Android 12.1.1) ends every handled
+/// touch — DOWN and UP alike — with a RequestFocus() for Avalonia's own view. The WebView's input
+/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
+/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
///
///
-/// So each accessory key hands focus straight back once its byte is on the wire. The page inside the
-/// WebView never noticed anything — its own DOM focus never moved — so regaining native focus re-establishes
-/// the same input connection and the keyboard settles back to what it was. Skipped when the WebView is still
-/// focused, which makes the call free on any platform arrangement where the steal never happened.
+/// Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.
+/// The first version called RequestFocus() from the keys' own Click handlers, which fire
+/// inside the UP event's dispatch — and the platform's own request runs after dispatch
+/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
+/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
+/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
+/// about to be made stale by the very mechanism this exists to counter.
+///
+///
+/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
+/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
///
///
/// Found by walking the decor view rather than asked of the NativeWebView control, because the
@@ -39,10 +46,18 @@ internal static class TerminalFocus
return;
}
- if (FindWebView(decor) is { IsFocused: false } webView)
+ if (FindWebView(decor) is not { } webView)
{
- webView.RequestFocus();
+ return;
}
+
+ webView.Post(() =>
+ {
+ if (!webView.IsFocused)
+ {
+ webView.RequestFocus();
+ }
+ });
}
private static View? FindWebView(ViewGroup parent)
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
index 3e2d417..916637a 100644
--- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
@@ -139,29 +139,27 @@ internal sealed partial class TerminalScreen : UserControl
{
var row = this.FindControl("AccessoryKeys")!;
+ // Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
+ // after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
+ // citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
+ // only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
+ // length of the press; and the Return is posted past the current dispatch, so its ordering against
+ // the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
+ row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
+ row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
+
foreach (var (label, bytes, latches) in Keys)
{
var key = CreateKey(label);
- // TerminalFocus.Return in both, after the key has done its work: by Click time the touch has
- // already moved Android's own focus onto Avalonia's input view, and leaving it there is what
- // swaps the keyboard out from over the terminal. Returning it is free when nothing moved.
if (latches)
{
controlKey = key;
- key.Click += (_, _) =>
- {
- ToggleControl();
- TerminalFocus.Return();
- };
+ key.Click += (_, _) => ToggleControl();
}
else
{
- key.Click += (_, _) =>
- {
- SendAsync(bytes);
- TerminalFocus.Return();
- };
+ key.Click += (_, _) => SendAsync(bytes);
}
row.Children.Add(key);
@@ -213,8 +211,9 @@ internal sealed partial class TerminalScreen : UserControl
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
// decides about its element — taking the keyboard's input connection off the terminal and
- // swapping its layout mid-typing. The Click wiring in BuildAccessoryRow hands that half
- // back; see TerminalFocus for the whole story.
+ // swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
+ // that half back; see TerminalFocus for the whole story, including why the handing back has
+ // to be posted rather than done inline.
Focusable = false,
};