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