using global::Android.OS; using global::Android.Views; using Avalonia.Threading; using DodoSSH.Client.Shell.Keyboard; namespace DodoSSH.Client.Android.Platform; /// /// Takes the keyboard away from the terminal's WebView, reports the taps that should bring it back, /// and turns a long press and drag into arrow keys. /// /// /// /// This replaces TerminalFocus, and it is the opposite move. That class handed Android's own /// view focus back to the WebView after Avalonia's DispatchTouchEvent took it, because /// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does: /// is the input target, and it is an Avalonia control, so the focus /// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight /// ends by removing one of the contestants rather than by refereeing it faster. /// /// /// Non-focusable, not non-interactive, and the distinction is the whole design. Focusability governs /// where keyboard and D-pad input go; it has nothing to do with touch delivery. A WebView with /// Focusable=false still receives, scrolls and hit-tests every touch it is given — so the page keeps /// its touch-action: pan-y scrollback exactly as before — but it can no longer take an input /// connection, and Chromium's own showSoftInput for the focused DOM node cannot succeed from an /// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the /// keyboard. /// /// /// Why a tap has to be reported at all. With the page unable to raise a keyboard, the ordinary way /// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The /// listener is attached with Handled = false throughout, so it observes without consuming: the /// WebView's own OnTouchEvent runs after it exactly as if nothing were listening. /// /// /// A tap is distinguished from a scroll the way every Android view does it, by /// ViewConfiguration.ScaledTouchSlop. Without that test, dragging the scrollback would raise the /// keyboard over the very output being scrolled to. /// /// /// A long press and a drag is the cursor gesture, and scrolling is still everything else. There is no /// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every /// event of it. A finger held still past becomes a joystick for the arrow /// keys — see for the speeds — and from that moment the rest of the touch is this /// class's: the WebView is sent a cancel, so it lets go of whatever it thought was starting, and is /// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing /// can be drawn over the terminal to say it instead. /// /// /// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium /// starts its own long press — selection, and a contextmenu event the page answers with a paste — at /// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the /// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so /// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it /// did before. /// /// /// Found by walking the decor view rather than asked of the NativeWebView control, for the reason /// TerminalFocus gave and which has not changed: the control does not expose its platform child, and /// this application only ever has the one WebView, so the walk's first match is necessarily the /// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no /// WebView until the terminal surface has been shown once. /// /// internal static class RendererFocus { /// The view already configured, so that showing the terminal twice does not listen twice. /// /// Weak, because this is a static holding an Android view: a strong reference would keep the whole view /// hierarchy, and the activity behind it, alive for the life of the process. /// private static WeakReference? configured; /// How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout. private const double CursorGestureDelay = 0.8; /// How often a held gesture is asked whether another arrow has come due — about one frame. private const long TickMs = 16; /// /// Makes the renderer unable to hold the keyboard, calls when somebody taps /// it, and while somebody long-presses and drags it. /// /// A tap: bring the keyboard back. /// Asked at each press, so the setting applies from the next touch. /// How many arrows to send, and which. /// /// Idempotent, and called on every transition into the terminal rather than once at startup, because the /// WebView does not exist until the surface it lives on has been shown — and because Android is /// free to tear its renderer down and build a new one, which is a case this head already handles /// elsewhere (see docs/android-port.md). /// public static void Suppress(Action onTapped, Func gesturesEnabled, Action onArrows) { if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor) { return; } if (FindWebView(decor) is not { } webView) { return; } if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView)) { return; } webView.Focusable = false; webView.FocusableInTouchMode = false; webView.ClearFocus(); WatchTouches(webView, onTapped, gesturesEnabled, onArrows); configured = new WeakReference(webView); } /// /// Calls for a tap, runs the cursor gesture for a long press, and stays out of /// the way of everything else. /// /// /// A tap is a press and a release that did not travel further than the platform's own /// ScaledTouchSlop — the same test every Android view uses, and necessary rather than fastidious: /// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The /// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway. /// private static void WatchTouches( global::Android.Webkit.WebView webView, Action onTapped, Func gesturesEnabled, Action onArrows) { var touch = new TouchState(webView, gesturesEnabled, onArrows); webView.Touch += (_, e) => { if (e.Event is not { } motion) { e.Handled = false; return; } // Observed and passed on, except while the cursor gesture holds the finger: then the page must not // see it scroll. e.Handled = touch.Handle(motion, onTapped); }; } /// One finger's worth of touch on the terminal, from press to release. /// /// /// A class rather than locals captured by the handler, because two timers — the long press and the /// repeat tick — read and write the same state from outside the touch callback. /// /// /// Avalonia's rather than View.PostDelayed. On this head the /// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the /// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a /// posted Runnable is a Java peer that would want disposing for the life of a view that has none. /// /// private sealed class TouchState { private readonly global::Android.Webkit.WebView webView; private readonly Func gesturesEnabled; private readonly Action onArrows; private readonly ArrowGesture gesture = new(); private readonly int slop; private readonly float density; private readonly DispatcherTimer longPress; private readonly DispatcherTimer tick; private float downX; private float downY; private float lastX; private float lastY; private long downTime; private bool moved; private bool steering; private int lastSpeed; internal TouchState( global::Android.Webkit.WebView webView, Func gesturesEnabled, Action onArrows) { this.webView = webView; this.gesturesEnabled = gesturesEnabled; this.onArrows = onArrows; // The fallbacks are the platform's own historical defaults, for a context that has somehow gone. slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8; density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1; longPress = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay), }; longPress.Tick += (_, _) => BeginSteering(); tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) }; tick.Tick += (_, _) => Tick(); } /// Whether the event is consumed rather than passed on to the page. internal bool Handle(MotionEvent motion, Action onTapped) { switch (motion.ActionMasked) { case MotionEventActions.Down: Press(motion); return false; case MotionEventActions.PointerDown: // A second finger is a pinch or a two-finger scroll, never the cursor gesture. longPress.Stop(); moved = true; return steering; case MotionEventActions.Move when steering: Steer(motion.GetX(), motion.GetY()); return true; case MotionEventActions.Move: Travel(motion.GetX(), motion.GetY()); return false; case MotionEventActions.Up or MotionEventActions.Cancel: return Release(motion.ActionMasked is MotionEventActions.Up, onTapped); default: return steering; } } private void Press(MotionEvent motion) { downX = lastX = motion.GetX(); downY = lastY = motion.GetY(); downTime = motion.DownTime; moved = false; steering = false; longPress.Stop(); if (gesturesEnabled()) { longPress.Start(); } } /// A finger moving before the long press: past the slop it is a scroll, and stays one. private void Travel(float x, float y) { lastX = x; lastY = y; if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop)) { moved = true; longPress.Stop(); } } /// Whether the release is consumed — it is, when it ends the cursor gesture. private bool Release(bool lifted, Action onTapped) { longPress.Stop(); if (steering) { EndSteering(); return true; } if (lifted && !moved) { onTapped(); } return false; } /// The finger has been held still long enough: take the touch and start steering. private void BeginSteering() { longPress.Stop(); steering = true; lastSpeed = 0; gesture.Reset(); // The page has seen a press and nothing since; a cancel tells it that press is over, so neither // its scroller nor its own long press goes on to do anything with it. Parents are asked not to // intercept either — the gesture now owns the finger until it lifts. var now = SystemClock.UptimeMillis(); using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None)) { if (cancel is not null) { webView.OnTouchEvent(cancel); cancel.Recycle(); } } webView.Parent?.RequestDisallowInterceptTouchEvent(true); webView.PerformHapticFeedback(FeedbackConstants.LongPress); tick.Start(); } private void Steer(float x, float y) { var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis()); Emit(count); } /// The repeat clock: whatever has come due since the last frame. private void Tick() { if (steering) { Emit(gesture.Advance(SystemClock.UptimeMillis())); } } private void Emit(int count) { if (count > 0) { onArrows(gesture.Direction, count); } // A tick under the thumb each time the speed shifts, which is the only way to feel the three // levels without looking: the terminal under the finger cannot draw anything to show them. if (gesture.Speed > lastSpeed && lastSpeed > 0) { webView.PerformHapticFeedback(FeedbackConstants.ClockTick); } lastSpeed = gesture.Speed; } private void EndSteering() { steering = false; tick.Stop(); gesture.Reset(); } } private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent) { for (var i = 0; i < parent.ChildCount; i++) { switch (parent.GetChildAt(i)) { case global::Android.Webkit.WebView webView: return webView; case ViewGroup child when FindWebView(child) is { } found: return found; } } return null; } }