From d1a45a3b1f045cfc8edea4197b544a45efa6e041 Mon Sep 17 00:00:00 2001 From: suxton Date: Tue, 10 Nov 2026 00:10:24 +0800 Subject: [PATCH 1/5] Add standard Android pointer capture to the native display --- .../display/base/CapturedPointerLayout.java | 28 ++ .../ui/vm/display/base/PointerButtons.java | 29 ++ .../ui/vm/display/base/PointerEventQueue.java | 67 ++++ .../display/base/StandardPointerCapture.java | 313 ++++++++++++++++++ .../base/TouchpadGestureRecognizer.java | 77 +++++ .../display/VMNativeDisplayActivity.java | 41 ++- .../nativedisplay/input/DirectInputSink.java | 2 +- .../res/layout/activity_vm_native_display.xml | 4 +- .../res/menu/menu_native_display_menu.xml | 8 + app/src/main/res/values-ar/strings.xml | 2 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../vm/display/base/PointerButtonsTest.java | 35 ++ .../display/base/PointerEventQueueTest.java | 84 +++++ .../base/TouchpadGestureRecognizerTest.java | 107 ++++++ 16 files changed, 794 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/CapturedPointerLayout.java create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtons.java create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueue.java create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizer.java create mode 100644 app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtonsTest.java create mode 100644 app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueueTest.java create mode 100644 app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizerTest.java diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/CapturedPointerLayout.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/CapturedPointerLayout.java new file mode 100644 index 000000000..bc8a6ce48 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/CapturedPointerLayout.java @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.LinearLayout; + +/** Intercept window-wide captured events before a focused IME child can consume them. */ +public final class CapturedPointerLayout extends LinearLayout { + public interface Handler { + boolean onEvent(MotionEvent event); + void onCaptureChanged(boolean captured); + } + private Handler handler; + public CapturedPointerLayout(Context context, AttributeSet attrs) { + super(context, attrs); + setFocusableInTouchMode(true); + } + public void setCaptureHandler(Handler handler) { this.handler = handler; } + @Override public boolean dispatchCapturedPointerEvent(MotionEvent event) { + return handler != null && handler.onEvent(event) || super.dispatchCapturedPointerEvent(event); + } + @Override public void onPointerCaptureChange(boolean captured) { + super.onPointerCaptureChange(captured); + if (handler != null) handler.onCaptureChanged(captured); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtons.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtons.java new file mode 100644 index 000000000..e73a23945 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtons.java @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +import java.util.HashMap; +import java.util.Map; + +/** A guest button remains held until every host device holding it releases it. */ +public final class PointerButtons { + private final Map devices = new HashMap<>(); + private final PointerEventQueue.Output output; + private int combined; + public PointerButtons(PointerEventQueue.Output output) { this.output = output; } + public void set(int deviceId, int buttons) { + if (buttons == 0) devices.remove(deviceId); + else devices.put(deviceId, buttons & 31); + int next = 0; + for (int value : devices.values()) next |= value; + int changed = combined ^ next; + combined = next; + for (int bit = 0; bit < 5; bit++) + if ((changed & (1 << bit)) != 0) output.button(0x110 + bit, (next & (1 << bit)) != 0); + } + public void reset() { + devices.clear(); + int held = combined; combined = 0; + for (int bit = 0; bit < 5; bit++) + if ((held & (1 << bit)) != 0) output.button(0x110 + bit, false); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueue.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueue.java new file mode 100644 index 000000000..6869b66fb --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueue.java @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +import java.util.ArrayDeque; +import java.util.concurrent.Executor; + +/** Coalesce adjacent deltas without moving motion across a button or wheel boundary. */ +public final class PointerEventQueue { + public interface Output { + void move(int x, int y); + void button(int code, boolean down); + void scroll(int vertical, int horizontal); + default void failed(RuntimeException error) { error.printStackTrace(); } + } + private static final int MOVE = 0, BUTTON = 1, SCROLL = 2; + private static final class Event { + final int kind; + int x, y; + Event(int kind, int x, int y) { this.kind = kind; this.x = x; this.y = y; } + } + private final ArrayDeque pending = new ArrayDeque<>(); + private final Executor executor; + private final Output output; + private boolean scheduled, closed; + public PointerEventQueue(Executor executor, Output output) { + this.executor = executor; this.output = output; + } + public void move(int x, int y) { if (x != 0 || y != 0) add(MOVE, x, y); } + public void button(int code, boolean down) { add(BUTTON, code, down ? 1 : 0); } + public void scroll(int v, int h) { if (v != 0 || h != 0) add(SCROLL, v, h); } + private synchronized void add(int kind, int x, int y) { + if (closed) return; + Event tail = pending.peekLast(); + if (tail != null && tail.kind == kind && (kind == MOVE + || kind == SCROLL && sameDirection(tail.x, x) && sameDirection(tail.y, y))) { + tail.x += x; tail.y += y; + } else pending.addLast(new Event(kind, x, y)); + if (!scheduled) { + scheduled = true; + executor.execute(this::drain); + } + } + private static boolean sameDirection(int before, int after) { + return before == 0 || after == 0 || (before > 0) == (after > 0); + } + private void drain() { + for (;;) { + Event event; + synchronized (this) { + event = pending.pollFirst(); + if (event == null) { scheduled = false; return; } + } + try { + if (event.kind == BUTTON) output.button(event.x, event.y != 0); + else if (event.x != 0 || event.y != 0) { + if (event.kind == MOVE) output.move(event.x, event.y); + else output.scroll(event.x, event.y); + } + } catch (RuntimeException error) { + // A failed frame must not prevent later button releases from draining. + output.failed(error); + } + } + } + /** Reject new input, but retain releases already queued by the owner. */ + public synchronized void close() { closed = true; } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java new file mode 100644 index 000000000..5c7fb47d5 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +import android.content.Context; +import android.content.SharedPreferences; +import android.hardware.input.InputManager; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.Log; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.MotionEvent; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import cn.classfun.droidvm.lib.store.vm.NativeDisplay; +import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.EvdevEncoder; +import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.InputForwarder; + +/** Android capture -> per-device translation -> ordered evdev frames -> existing VM sink. */ +public final class StandardPointerCapture implements CapturedPointerLayout.Handler, InputManager.InputDeviceListener { + private static final String TAG = "StandardPointerCapture"; + private final CapturedPointerLayout view; + private final InputManager inputManager; + private final Handler main = new Handler(Looper.getMainLooper()); + private final Runnable acquire = this::start; + private final SparseArray devices = new SparseArray<>(); + // Reuse one worker across sink replacement so old releases cannot arrive after new presses. + private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> new Thread(r, "CapturedPointer")); + private PointerEventQueue queue; + private PointerButtons buttons; + private boolean enabled, resumed, focused; + private boolean active, natural, systemNatural, closed; + + public StandardPointerCapture(CapturedPointerLayout view) { + this.view = view; + Context context = view.getContext(); + inputManager = context.getSystemService(InputManager.class); + view.setCaptureHandler(this); + } + public boolean isEnabled() { return enabled; } + private SharedPreferences preferences() { + return view.getContext().getSharedPreferences("pointer-grab", Context.MODE_PRIVATE); + } + public boolean isNaturalScroll() { return preferences().getBoolean("natural_scroll", true); } + public void setNaturalScroll(boolean value) { + if (value == isNaturalScroll()) return; + preferences().edit().putBoolean("natural_scroll", value).apply(); + updateCapture(); + } + public void setEnabled(boolean value) { + if (enabled == value) return; + enabled = value; + updateCapture(); + } + public void setResumed(boolean value) { + if (resumed == value) return; + resumed = value; + updateCapture(); + } + public void setFocused(boolean value) { + if (focused == value) return; + focused = value; + updateCapture(); + } + private boolean ready() { + return !closed && enabled && resumed && focused && queue != null; + } + private void updateCapture() { + main.removeCallbacks(acquire); + if (ready()) main.post(acquire); // Let menu and window focus callbacks finish first. + else stop(); + } + public void setSink(InputForwarder.InputSink sink) { + if (closed) return; + main.removeCallbacks(acquire); + stop(); + if (queue != null) queue.close(); + queue = null; buttons = null; + if (sink == null) return; + queue = new PointerEventQueue(worker, new PointerEventQueue.Output() { + private void write(byte[] data) { + if (data == null) return; + try { + if (!sink.write(NativeDisplay.MOUSE, data)) Log.w(TAG, "Pointer frame was not delivered"); + } catch (RuntimeException error) { Log.e(TAG, "Pointer write failed", error); } + } + public void move(int x, int y) { write(EvdevEncoder.encodeMouseMove(x, y)); } + public void button(int code, boolean down) { write(EvdevEncoder.encodeMouseButton((short) code, down)); } + public void scroll(int v, int h) { write(EvdevEncoder.encodeMouseWheel(v, h)); } + public void failed(RuntimeException error) { Log.e(TAG, "Pointer encoding failed", error); } + }); + buttons = new PointerButtons(new PointerEventQueue.Output() { + public void move(int x, int y) { queue.move(x, y); } + public void button(int code, boolean down) { queue.button(code, down); } + public void scroll(int v, int h) { queue.scroll(v, h); } + }); + updateCapture(); + } + private void start() { + if (!ready() || !view.hasWindowFocus()) return; + boolean natural = isNaturalScroll(); + if (this.natural != natural) resetDevices(); + this.natural = natural; + systemNatural = readSystemNaturalScroll(); + if (active && view.hasPointerCapture()) return; + if (!view.hasFocus() && !view.requestFocus()) return; + if (!active && inputManager != null) + inputManager.registerInputDeviceListener(this, main); + active = true; + view.requestPointerCapture(); + } + private void stop() { + if (active && inputManager != null) inputManager.unregisterInputDeviceListener(this); + boolean requested = active; + active = false; + if (requested || view.hasPointerCapture()) view.releasePointerCapture(); + resetDevices(); + } + public void close() { close(null); } + /** Complete cleanup on the pointer worker after all queued releases have drained. */ + public void close(Runnable afterDrain) { + if (closed) return; + setSink(null); closed = true; + if (afterDrain != null) worker.execute(afterDrain); + worker.shutdown(); // drain the old sink's releases before retiring the worker + view.setCaptureHandler(null); + } + public void requestAfterClick(MotionEvent event) { + if (ready() && active && !view.hasPointerCapture() && (event.isFromSource(InputDevice.SOURCE_MOUSE) + || event.isFromSource(InputDevice.SOURCE_TOUCHPAD))) { + if (!view.hasFocus()) view.requestFocus(); + view.requestPointerCapture(); + } + } + @Override public void onCaptureChanged(boolean captured) { + if (!captured) resetDevices(); + Log.i(TAG, captured ? "Android pointer capture acquired" : "Android pointer capture released"); + } + private void resetDevices() { + for (int i = 0; i < devices.size(); i++) devices.valueAt(i).reset(); + devices.clear(); + if (buttons != null) buttons.reset(); + } + @Override public void onInputDeviceAdded(int id) { } + @Override public void onInputDeviceChanged(int id) { removeDevice(id); } + @Override public void onInputDeviceRemoved(int id) { removeDevice(id); } + private void removeDevice(int id) { + DeviceState device = devices.get(id); + if (device != null) { device.reset(); devices.remove(id); } + if (buttons != null) buttons.set(id, 0); + } + @Override public boolean onEvent(MotionEvent event) { + if (!active || queue == null) return false; + if (!event.isFromSource(InputDevice.SOURCE_TOUCHPAD) + && !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) + && !event.isFromSource(InputDevice.SOURCE_MOUSE)) return false; + int id = event.getDeviceId(); + if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { removeDevice(id); return true; } + boolean raw = event.isFromSource(InputDevice.SOURCE_TOUCHPAD); + DeviceState state = devices.get(id); + // Sources can change when Android switches its touchpad capture mode. + if (state != null && state.raw != raw) { removeDevice(id); state = null; } + if (state == null) { + state = new DeviceState(id, event.getDevice(), raw); + devices.put(id, state); + Log.i(TAG, String.format("captured %s source=0x%x device=%s", + raw ? "contacts" : state.touchpad ? "touchpad gestures" : "mouse", + event.getSource(), event.getDevice() == null ? "unknown" : event.getDevice().getName())); + } + if (raw) state.contacts(event); + else state.relative(event); + return true; + } + private boolean readSystemNaturalScroll() { + try { + return Settings.System.getInt(view.getContext().getContentResolver(), "touchpad_natural_scrolling", 1) != 0; + } catch (SecurityException ignored) { return true; } + } + private final class DeviceState implements PointerEventQueue.Output { + final int id; + final boolean raw, touchpad; + final TouchpadGestureRecognizer pad; + final float xMin, yMin, xResolution, yResolution; + float moveX, moveY, wheelV, wheelH, hoverX, hoverY; + boolean hoverValid; + int held, rawButtons; + DeviceState(int id, InputDevice device, boolean raw) { + this.id = id; this.raw = raw; + touchpad = raw || device != null && device.supportsSource(InputDevice.SOURCE_TOUCHPAD); + pad = raw ? new TouchpadGestureRecognizer(this, natural) : null; + InputDevice.MotionRange x = range(device, MotionEvent.AXIS_X); + InputDevice.MotionRange y = range(device, MotionEvent.AXIS_Y); + xMin = x == null ? 0 : x.getMin(); yMin = y == null ? 0 : y.getMin(); + xResolution = resolution(x, 100); yResolution = resolution(y, 60); + } + public void move(int x, int y) { queue.move(x, y); } + public void scroll(int v, int h) { queue.scroll(v, h); } + public void button(int code, boolean down) { + int bit = 1 << (code - 0x110); + held = down ? held | bit : held & ~bit; + buttons.set(id, held); + } + void move(float x, float y) { + moveX += x; moveY += y; + int dx = (int) moveX, dy = (int) moveY; + moveX -= dx; moveY -= dy; + queue.move(dx, dy); + } + void relative(MotionEvent event) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_HOVER_MOVE) { + for (int h = 0; h < event.getHistorySize(); h++) + move(event.getHistoricalX(h), event.getHistoricalY(h)); + move(event.getX(), event.getY()); + } + if (action == MotionEvent.ACTION_SCROLL) wheel(event); + held = buttonState(event) & 31; + buttons.set(id, held); + } + void wheel(MotionEvent event) { + // Relative touchpad gestures have already had the system direction applied. + // A normal mouse wheel must never inherit the touchpad preference. + float sign = !touchpad ? 1 : raw ? (natural ? -1 : 1) + : natural == systemNatural ? 1 : -1; + for (int h = 0; h <= event.getHistorySize(); h++) { + boolean current = h == event.getHistorySize(); + wheelV += (current ? event.getAxisValue(MotionEvent.AXIS_VSCROLL) + : event.getHistoricalAxisValue(MotionEvent.AXIS_VSCROLL, h)) * sign; + wheelH += (current ? event.getAxisValue(MotionEvent.AXIS_HSCROLL) + : event.getHistoricalAxisValue(MotionEvent.AXIS_HSCROLL, h)) * sign; + } + int v = (int) wheelV, h = (int) wheelH; + wheelV -= v; wheelH -= h; + queue.scroll(v, h); + } + void contacts(MotionEvent event) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_SCROLL) { + pad.cancelTap(); wheel(event); setRawButtons(event.getButtonState()); hoverValid = false; + } else if (action == MotionEvent.ACTION_BUTTON_PRESS || action == MotionEvent.ACTION_BUTTON_RELEASE) { + setRawButtons(buttonState(event)); + } else if (action == MotionEvent.ACTION_HOVER_MOVE || action == MotionEvent.ACTION_HOVER_ENTER + || action == MotionEvent.ACTION_HOVER_EXIT) { + for (int h = 0; h < event.getHistorySize(); h++) hover(event, h); + hover(event, -1); + if (action == MotionEvent.ACTION_HOVER_EXIT) hoverValid = false; + setRawButtons(event.getButtonState()); + } else if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_POINTER_UP + || action == MotionEvent.ACTION_MOVE) { + hoverValid = false; + for (int h = 0; h < event.getHistorySize(); h++) sample(event, h); + sample(event, -1); + } + } + void setRawButtons(int next) { rawButtons = next; pad.setButtons(next); } + void hover(MotionEvent event, int history) { + float x = ((history < 0 ? event.getX() : event.getHistoricalX(history)) - xMin) / xResolution; + float y = ((history < 0 ? event.getY() : event.getHistoricalY(history)) - yMin) / yResolution; + float dx = history < 0 ? event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) + : event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_X, history); + float dy = history < 0 ? event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) + : event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_Y, history); + if (dx != 0 || dy != 0) move(dx, dy); + else if (hoverValid) move((x - hoverX) * 12, (y - hoverY) * 12); + hoverX = x; hoverY = y; hoverValid = true; + } + void sample(MotionEvent event, int history) { + int action = event.getActionMasked(); + int up = history < 0 && (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) + ? event.getActionIndex() : -1; + long fingers = 0; + float x = 0, y = 0; + int count = 0; + for (int i = 0; i < event.getPointerCount(); i++) { + int tool = event.getToolType(i); + if (i == up || tool != MotionEvent.TOOL_TYPE_FINGER && tool != MotionEvent.TOOL_TYPE_UNKNOWN) continue; + fingers |= 1L << event.getPointerId(i); count++; + x += ((history < 0 ? event.getX(i) : event.getHistoricalX(i, history)) - xMin) / xResolution; + y += ((history < 0 ? event.getY(i) : event.getHistoricalY(i, history)) - yMin) / yResolution; + } + pad.frame(fingers, count == 0 ? 0 : x / count, count == 0 ? 0 : y / count, + history < 0 ? event.getEventTime() : event.getHistoricalEventTime(history), + history < 0 ? event.getButtonState() : rawButtons); + if (history < 0) rawButtons = event.getButtonState(); + } + void reset() { + if (pad != null) pad.reset(); + held = 0; buttons.set(id, 0); + } + } + private static int buttonState(MotionEvent event) { + // The action button can be newer than the reported mask on press and release. + int buttons = event.getButtonState(); + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_BUTTON_PRESS) return buttons | event.getActionButton(); + if (action == MotionEvent.ACTION_BUTTON_RELEASE) return buttons & ~event.getActionButton(); + return buttons; + } + private static InputDevice.MotionRange range(InputDevice device, int axis) { + if (device == null) return null; + InputDevice.MotionRange range = device.getMotionRange(axis, InputDevice.SOURCE_TOUCHPAD); + return range == null ? device.getMotionRange(axis) : range; + } + private static float resolution(InputDevice.MotionRange range, float fallbackSizeMm) { + if (range == null) return 1; + if (range.getResolution() > 0) return range.getResolution(); + // Android does not require physical dimensions. Use a generic nominal pad size only + // when the driver omits resolution; never match a vendor, product, or device name. + return range.getRange() > 0 ? range.getRange() / fallbackSizeMm : 1; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizer.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizer.java new file mode 100644 index 000000000..788e3068d --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizer.java @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +/** Fallback for Android versions that capture raw contacts instead of system gestures. + * Positions are millimetres; pointer IDs form a stable mask, independent of index order. + */ +public final class TouchpadGestureRecognizer { + private static final int LEFT = 0x110, RIGHT = 0x111; + private final PointerEventQueue.Output out; + private final boolean natural; + private long fingers, started; + private int count, maxCount, buttons, primary, emittedButtons; + private float lastX, lastY, travel, moveX, moveY, wheelX, wheelY; + private boolean clicked; + public TouchpadGestureRecognizer(PointerEventQueue.Output out, boolean natural) { + this.out = out; this.natural = natural; + } + public void frame(long nextFingers, float xMm, float yMm, long time, int nextButtons) { + int nextCount = Long.bitCount(nextFingers); + if (count == 0 && nextCount > 0) { + started = time; travel = 0; maxCount = 0; clicked = buttons != 0; + clearRemainders(); + } + maxCount = Math.max(maxCount, nextCount); + // A primary press with two raw contacts is a secondary click. + updateButtons(nextButtons, nextCount); + if (nextCount > 0 && nextFingers == fingers) { + float dx = xMm - lastX, dy = yMm - lastY; + travel += (float) Math.hypot(dx, dy); + if (nextCount == 1 || primary != 0) { + moveX += dx * 12; moveY += dy * 12; + int x = (int) moveX, y = (int) moveY; + moveX -= x; moveY -= y; + if (x != 0 || y != 0) out.move(x, y); + } else if (nextCount == 2) { + wheelX += dx / 3; wheelY -= dy / 3; + int h = (int) wheelX, v = (int) wheelY; + wheelX -= h; wheelY -= v; + if (h != 0 || v != 0) out.scroll(natural ? -v : v, natural ? -h : h); + } + } + if (nextCount == 0 && count > 0 && !clicked && travel < 2 + && time - started <= 250 && maxCount <= 2) { + int code = maxCount == 2 ? RIGHT : LEFT; + out.button(code, true); out.button(code, false); + } + if (nextFingers != fingers) clearRemainders(); + fingers = nextFingers; count = nextCount; lastX = xMm; lastY = yMm; + } + public void setButtons(int next) { updateButtons(next, count); } + private void updateButtons(int next, int contacts) { + next &= 31; + if (((buttons ^ next) & 1) != 0) { + if ((next & 1) != 0) { + primary = contacts >= 2 ? RIGHT : LEFT; + clicked = true; + } else primary = 0; + } + if (((buttons | next) & ~1) != 0) cancelTap(); + // Remapped primary and explicit secondary can both own the guest right button. + // Emit their union so releasing either source cannot release the other source. + int nextEmitted = (next & ~1) | (primary == 0 ? 0 : 1 << (primary - LEFT)); + int changed = emittedButtons ^ nextEmitted; + emittedButtons = nextEmitted; + for (int bit = 0; bit < 5; bit++) + if ((changed & (1 << bit)) != 0) + out.button(LEFT + bit, (nextEmitted & (1 << bit)) != 0); + buttons = next; + } + private void clearRemainders() { moveX = moveY = wheelX = wheelY = 0; } + public void cancelTap() { clicked = true; } + public void reset() { + updateButtons(0, 0); + fingers = 0; count = maxCount = 0; clicked = false; + clearRemainders(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.java index 13c25b384..b8aab9ca6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.java @@ -60,6 +60,7 @@ import cn.classfun.droidvm.lib.ui.MaterialMenu; import cn.classfun.droidvm.ui.vm.display.base.DaemonDisplayAttach; import cn.classfun.droidvm.ui.vm.display.base.PhysicalKeyboardGrab; +import cn.classfun.droidvm.ui.vm.display.base.StandardPointerCapture; import cn.classfun.droidvm.ui.vm.display.base.DisplayChromeController; import cn.classfun.droidvm.ui.vm.display.base.DisplayExtraKeysPanel; import cn.classfun.droidvm.ui.vm.display.base.DisplayKeyboardMenuRow; @@ -271,6 +272,7 @@ public void onZoomPan(float scaleFactor, float dxView, float dyView, // Asks the daemon to take the host's physical keyboard for this console while it is in // front and no IME wants it; see PhysicalKeyboardGrab for the whole of the rule. private PhysicalKeyboardGrab keyboardGrab; + private StandardPointerCapture pointerGrab; // Bits of the lamp masks the daemon sends, which are the Linux LED_* codes: NUML 0, CAPSL 1, // SCROLLL 2. private static final int LED_MASK_NUM = 1; @@ -295,8 +297,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { // Before the views: applyInitial() runs while they are being built and tells this // the keyboard mode it starts in. keyboardGrab = new PhysicalKeyboardGrab(vmId, () -> screenId, screenInputEnabled); - bindViews(); + pointerGrab = new StandardPointerCapture(findViewById(R.id.main)); toolbar.setTitle(vmName.isEmpty() ? getString(R.string.native_display_title) : vmName); toolbar.setNavigationOnClickListener(v -> finish()); setupViews(); @@ -323,6 +325,7 @@ public void onLost() { // The grab has no such fallback: the daemon is the only process that can hold // it, and it has already dropped it with the token. keyboardGrab.setService(null); + pointerGrab.setSink(null); } }); displayAttach.start(); @@ -333,6 +336,7 @@ private void onRootConnected(@NonNull INativeDisplayRootService service) { // Try a direct unix-socket sink to the daemon (one write per evdev frame, no IPC // round-trip); on any failure it falls back to the vm_input JSON-RPC path below. directSink = new DirectInputSink(vmId, () -> screenId, service, this::sendInputToDaemon); + pointerGrab.setSink(directSink); inputForwarder = new InputForwarder(directSink); if (nativeExtraKeys != null) nativeExtraKeys.setForwarder(inputForwarder); // A forwarder is built fresh on every attach and starts in TOUCH, so it has to be told @@ -686,6 +690,8 @@ private RectF displayRectInContainer() { // Button presses are ALWAYS consumed - an unhandled BUTTON_SECONDARY press is what makes the // framework synthesize a BACK key, which must never fire inside the VM display. private boolean onSurfaceGenericMotion(View v, MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_BUTTON_PRESS) + pointerGrab.requestAfterClick(event); switch (event.getActionMasked()) { case MotionEvent.ACTION_SCROLL: if (inputForwarder != null) { @@ -755,6 +761,11 @@ private boolean sendInputToDaemon(int channel, @NonNull byte[] data) { @Override public boolean dispatchKeyEvent(@NonNull KeyEvent event) { + if (pointerGrab != null && pointerGrab.isEnabled() + && event.getKeyCode() == KeyEvent.KEYCODE_VOLUME_DOWN) { + if (event.getAction() == KeyEvent.ACTION_DOWN) pointerGrab.setEnabled(false); + return true; + } int keyCode = event.getKeyCode(); // A hardware-mouse right-click the framework (or OEM ROM) failed to see consumed gets // synthesized as a BACK key - sometimes mouse-sourced, sometimes (OEM injection) claiming @@ -934,6 +945,8 @@ private void toggleFullscreen() { private void showFabMenu() { var popup = new MaterialMenu(this, fabMenu); popup.inflate(R.menu.menu_native_display_menu); + popup.getMenu().findItem(R.id.menu_pointer_grab).setChecked(pointerGrab.isEnabled()); + popup.getMenu().findItem(R.id.menu_pointer_natural_scroll).setChecked(pointerGrab.isNaturalScroll()); var header = new LinearLayout(this); header.setOrientation(LinearLayout.VERTICAL); header.addView(buildInputModeHeader(popup)); @@ -981,7 +994,13 @@ private void hideSoftKeyboard() { private boolean onMenuItemClicked(@NonNull MenuItem item) { int id = item.getItemId(); - if (id == R.id.menu_fullscreen) { + if (id == R.id.menu_pointer_grab) { + pointerGrab.setEnabled(!pointerGrab.isEnabled()); + return true; + } else if (id == R.id.menu_pointer_natural_scroll) { + pointerGrab.setNaturalScroll(!pointerGrab.isNaturalScroll()); + return true; + } else if (id == R.id.menu_fullscreen) { toggleFullscreen(); return true; } else if (id == R.id.menu_rotate) { @@ -1114,6 +1133,12 @@ public void onCleared() { }); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (pointerGrab != null) pointerGrab.setFocused(hasFocus); + } + @Override protected void onResume() { super.onResume(); @@ -1124,6 +1149,7 @@ protected void onResume() { // from eating multi-finger input meant for the guest (see SystemGestureGuard). SystemGestureGuard.enterDisplay(); keyboardGrab.setResumed(true); + pointerGrab.setResumed(true); } @Override @@ -1132,12 +1158,19 @@ protected void onPause() { GamePerfHint.exitGameplay(this); SystemGestureGuard.exitDisplay(); keyboardGrab.setResumed(false); + pointerGrab.setResumed(false); } @Override protected void onDestroy() { super.onDestroy(); if (keyboardGrab != null) keyboardGrab.close(); + // Keep the old sink alive until the capture worker has sent its final releases. + // Waiting on the UI thread would block lifecycle callbacks on a slow transport. + var retiringSink = directSink; + directSink = null; + if (pointerGrab != null) pointerGrab.close(retiringSink == null ? null : retiringSink::close); + else if (retiringSink != null) retiringSink.close(); if (displaySource != null) { displaySource.shutdown(); displaySource = null; @@ -1146,10 +1179,6 @@ protected void onDestroy() { inputForwarder.close(); inputForwarder = null; } - if (directSink != null) { - directSink.close(); - directSink = null; - } if (displayAttach != null) { displayAttach.stop(); displayAttach = null; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.java index a7be181e9..9eae6cd43 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.java @@ -83,7 +83,7 @@ public boolean wasLastWriteDirect() { return lastWriteDirect; } - /** Stops using the direct path; subsequent writes go through the fallback. */ + /** Rejects further writes. Owners must drain pending input before calling this. */ public void close() { closed = true; } diff --git a/app/src/main/res/layout/activity_vm_native_display.xml b/app/src/main/res/layout/activity_vm_native_display.xml index 413d24268..332de8aac 100644 --- a/app/src/main/res/layout/activity_vm_native_display.xml +++ b/app/src/main/res/layout/activity_vm_native_display.xml @@ -1,5 +1,5 @@ - - + diff --git a/app/src/main/res/menu/menu_native_display_menu.xml b/app/src/main/res/menu/menu_native_display_menu.xml index 5d8e51f75..f1fbf251d 100644 --- a/app/src/main/res/menu/menu_native_display_menu.xml +++ b/app/src/main/res/menu/menu_native_display_menu.xml @@ -11,4 +11,12 @@ android:icon="@drawable/ic_rotate" android:title="@string/vnc_menu_rotate" /> + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d1a32fd45..0cc9dacc9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1735,4 +1735,6 @@ 🔒 %1$s يبقى %1$s مع المضيف: تعذر على أحد برامج التشغيل تحريره، لذا لن يتحول إلى خامل إلا عند فصله + التقاط الفأرة أو لوحة اللمس (تجريبي) + التمرير الطبيعي للوحة اللمس diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5b0137ac8..cb1235102 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1671,4 +1671,6 @@ 设备没有移动 被其他程序占用 %1$s 留在主机:有驱动不肯卸载,要拔掉重插才会回到 Idle + 捕获鼠标 / 触控板(实验) + 触控板自然滚动 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 85b813982..2c83ad051 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1671,4 +1671,6 @@ 裝置沒有移動 被其他程式占用 %1$s 留在主機:有驅動不肯卸載,要拔掉重插才會回到 Idle + 擷取滑鼠 / 觸控板(實驗) + 觸控板自然捲動 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 386a8b81a..99a2d78b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1729,4 +1729,6 @@ 🔒 %1$s %1$s stays with the host: a driver of its would not let go, so it goes idle only when it is unplugged + Capture mouse / touchpad (experimental) + Natural touchpad scrolling diff --git a/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtonsTest.java b/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtonsTest.java new file mode 100644 index 000000000..24e8b64fc --- /dev/null +++ b/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerButtonsTest.java @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +import org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import static org.junit.Assert.*; + +public class PointerButtonsTest { + private final List events = new ArrayList<>(); + private final PointerButtons buttons = new PointerButtons(new PointerEventQueue.Output() { + public void move(int x, int y) { } + public void button(int code, boolean down) { events.add(String.format("%d %s", code, down)); } + public void scroll(int v, int h) { } + }); + @Test public void idleTouchpadDoesNotReleaseHeldMouseButton() { + buttons.set(1, 1); buttons.set(2, 0); buttons.set(1, 0); + assertEquals(List.of("272 true", "272 false"), events); + } + @Test public void twoDevicesCanHoldTheSameButton() { + buttons.set(1, 1); buttons.set(2, 1); buttons.set(1, 0); + assertEquals(List.of("272 true"), events); + buttons.set(2, 0); + assertEquals(List.of("272 true", "272 false"), events); + } + @Test public void disconnectReleasesOnlyTheRemovedDeviceButtons() { + buttons.set(1, 1); buttons.set(2, 2); buttons.set(1, 0); + assertEquals(List.of("272 true", "273 true", "272 false"), events); + } + @Test public void focusLossReleasesEveryButtonExactlyOnce() { + buttons.set(1, 31); buttons.set(2, 31); events.clear(); + buttons.reset(); buttons.reset(); + assertEquals(List.of("272 false", "273 false", "274 false", "275 false", "276 false"), events); + } +} diff --git a/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueueTest.java b/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueueTest.java new file mode 100644 index 000000000..111e7c8c3 --- /dev/null +++ b/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/PointerEventQueueTest.java @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +import org.junit.Test; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import static org.junit.Assert.*; + +public class PointerEventQueueTest { + private final ArrayDeque worker = new ArrayDeque<>(); + private final List events = new ArrayList<>(); + private final PointerEventQueue queue = new PointerEventQueue(worker::addLast, new PointerEventQueue.Output() { + public void move(int x, int y) { events.add(String.format("move %d %d", x, y)); } + public void button(int code, boolean down) { events.add(String.format("button %d %s", code, down)); } + public void scroll(int v, int h) { events.add(String.format("scroll %d %d", v, h)); } + }); + private void drain() { while (!worker.isEmpty()) worker.removeFirst().run(); } + @Test public void highRateMotionUsesOneWorkerAndOneAccumulatedFrame() { + for (int i = 0; i < 10000; i++) queue.move(1, -1); + assertEquals(1, worker.size()); drain(); + assertEquals(List.of("move 10000 -10000"), events); + } + @Test public void dragMotionCannotCrossPressOrRelease() { + queue.move(1, 0); queue.button(272, true); queue.move(2, 0); + queue.button(272, false); queue.move(3, 0); drain(); + assertEquals(List.of("move 1 0", "button 272 true", "move 2 0", "button 272 false", "move 3 0"), events); + } + @Test public void wheelIsABarrierBetweenPointerMoves() { + queue.move(1, 0); queue.scroll(1, 0); queue.scroll(2, 0); queue.move(3, 0); drain(); + assertEquals(List.of("move 1 0", "scroll 3 0", "move 3 0"), events); + } + @Test public void closeDrainsReleaseAndRejectsNewInput() { + queue.button(272, true); queue.button(272, false); queue.close(); queue.button(272, true); drain(); + assertEquals(List.of("button 272 true", "button 272 false"), events); + } + @Test public void oppositeDeltasDoNotEmitEmptyFrames() { + queue.move(5, 6); queue.move(-5, -6); drain(); + assertTrue(events.isEmpty()); + } + @Test public void oppositeWheelDirectionsAreDeliveredInOrder() { + queue.scroll(1, 0); queue.scroll(-1, 0); drain(); + assertEquals(List.of("scroll 1 0", "scroll -1 0"), events); + } + @Test public void horizontalReversalIsABarrierEvenIfVerticalDirectionMatches() { + queue.scroll(1, 2); queue.scroll(1, -1); drain(); + assertEquals(List.of("scroll 1 2", "scroll 1 -1"), events); + } + @Test public void reversingAtTheTopOfAPageStillScrollsDown() { + int[] position = {0}; + var q = new PointerEventQueue(worker::addLast, new PointerEventQueue.Output() { + public void move(int x, int y) { } + public void button(int code, boolean down) { } + public void scroll(int v, int h) { position[0] = Math.max(0, position[0] - v); } + }); + q.scroll(1, 0); q.scroll(-1, 0); drain(); + assertEquals(1, position[0]); + } + @Test public void acceptsNewWorkAfterTheQueueEmpties() { + queue.move(1, 0); drain(); queue.move(2, 0); drain(); + assertEquals(List.of("move 1 0", "move 2 0"), events); + } + @Test public void writeFailureDoesNotDropRelease() { + List result = new ArrayList<>(); + var q = new PointerEventQueue(worker::addLast, new PointerEventQueue.Output() { + public void move(int x, int y) { throw new IllegalStateException("transport failed"); } + public void button(int code, boolean down) { result.add("released"); } + public void scroll(int v, int h) { } + public void failed(RuntimeException error) { result.add("failed"); } + }); + q.move(1, 0); q.button(272, false); drain(); + assertEquals(List.of("failed", "released"), result); + } + @Test public void sinkReplacementDrainsOldReleasesBeforeNewPresses() { + queue.button(272, false); queue.close(); + var replacement = new PointerEventQueue(worker::addLast, new PointerEventQueue.Output() { + public void move(int x, int y) { } + public void button(int code, boolean down) { events.add("new press"); } + public void scroll(int v, int h) { } + }); + replacement.button(272, true); drain(); + assertEquals(List.of("button 272 false", "new press"), events); + } +} diff --git a/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizerTest.java b/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizerTest.java new file mode 100644 index 000000000..594857bda --- /dev/null +++ b/app/src/test/java/cn/classfun/droidvm/ui/vm/display/base/TouchpadGestureRecognizerTest.java @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package cn.classfun.droidvm.ui.vm.display.base; + +import org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import static org.junit.Assert.*; + +public class TouchpadGestureRecognizerTest { + private final List events = new ArrayList<>(); + private final PointerEventQueue.Output out = new PointerEventQueue.Output() { + public void move(int x, int y) { events.add(String.format("move %d %d", x, y)); } + public void button(int code, boolean down) { events.add(String.format("button %d %s", code, down)); } + public void scroll(int v, int h) { events.add(String.format("scroll %d %d", v, h)); } + }; + private final TouchpadGestureRecognizer pad = new TouchpadGestureRecognizer(out, true); + @Test public void singleFingerTapClicksLeft() { + pad.frame(1, 10, 10, 100, 0); pad.frame(0, 0, 0, 180, 0); + assertEquals(List.of("button 272 true", "button 272 false"), events); + } + @Test public void staggeredTwoFingerTapDoesNotJump() { + pad.frame(1, 10, 10, 100, 0); pad.frame(3, 30, 30, 120, 0); + pad.frame(1, 10, 10, 150, 0); pad.frame(0, 0, 0, 180, 0); + assertEquals(List.of("button 273 true", "button 273 false"), events); + } + @Test public void physicalMillimetresHaveEqualSpeedOnBothAxes() { + pad.frame(1, 10, 10, 100, 0); pad.frame(1, 13, 13, 120, 0); + pad.frame(0, 0, 0, 180, 0); + assertEquals(List.of("move 36 36"), events); + } + @Test public void subNotchScrollAccumulates() { + pad.frame(3, 10, 10, 100, 0); pad.frame(3, 10, 11, 120, 0); + pad.frame(3, 10, 12, 140, 0); assertTrue(events.isEmpty()); + pad.frame(3, 10, 13, 160, 0); + assertEquals(List.of("scroll 1 0"), events); + } + @Test public void naturalHorizontalScroll() { + pad.frame(3, 10, 10, 100, 0); pad.frame(3, 13, 10, 120, 0); + assertEquals(List.of("scroll 0 -1"), events); + } + @Test public void traditionalScroll() { + var traditional = new TouchpadGestureRecognizer(out, false); + traditional.frame(3, 10, 10, 100, 0); traditional.frame(3, 10, 13, 120, 0); + assertEquals(List.of("scroll -1 0"), events); + } + @Test public void physicalDragReleasesOnReset() { + pad.frame(1, 10, 10, 100, 1); pad.frame(1, 11, 10, 120, 1); pad.reset(); + assertEquals(List.of("button 272 true", "move 12 0", "button 272 false"), events); + } + @Test public void explicitScrollCancelsPendingTap() { + pad.frame(1, 10, 10, 100, 0); pad.cancelTap(); pad.frame(0, 0, 0, 180, 0); + assertTrue(events.isEmpty()); + } + @Test public void buttonOnlyEventsDoNotCreateContacts() { + pad.setButtons(1); pad.setButtons(0); pad.reset(); + assertEquals(List.of("button 272 true", "button 272 false"), events); + } + @Test public void secondaryButtonDoesNotGenerateLeftTap() { + pad.frame(1, 10, 10, 100, 2); pad.frame(0, 0, 0, 180, 0); + assertEquals(List.of("button 273 true", "button 273 false"), events); + } + @Test public void threeFingersDoNotTap() { + pad.frame(7, 10, 10, 100, 0); pad.frame(0, 0, 0, 180, 0); assertTrue(events.isEmpty()); + } + @Test public void longPressDoesNotTap() { + pad.frame(1, 10, 10, 100, 0); pad.frame(0, 0, 0, 500, 0); assertTrue(events.isEmpty()); + } + @Test public void replacedFingerRebasesWithoutMovement() { + pad.frame(1, 10, 10, 100, 0); pad.frame(2, 80, 50, 120, 0); assertTrue(events.isEmpty()); + } + @Test public void highPointerIdsStayDistinct() { + pad.frame(1L << 31, 10, 10, 100, 0); + pad.frame((1L << 31) | 1, 30, 30, 120, 0); + pad.frame(0, 0, 0, 180, 0); + assertEquals(List.of("button 273 true", "button 273 false"), events); + } + @Test public void twoFingerPhysicalPressUsesSecondaryButton() { + pad.frame(3, 10, 10, 100, 1); pad.frame(0, 0, 0, 180, 0); + assertEquals(List.of("button 273 true", "button 273 false"), events); + } + @Test public void resetPreventsAPhantomTapAfterCaptureLoss() { + pad.frame(1, 10, 10, 100, 0); pad.reset(); pad.frame(0, 0, 0, 180, 0); + assertTrue(events.isEmpty()); + } + @Test public void remappedPrimaryReleasePreservesExplicitSecondary() { + pad.frame(3, 10, 10, 100, 1); + pad.setButtons(3); pad.setButtons(2); + assertEquals(List.of("button 273 true"), events); + pad.setButtons(0); + assertEquals(List.of("button 273 true", "button 273 false"), events); + } + @Test public void explicitSecondaryReleasePreservesRemappedPrimary() { + pad.frame(3, 10, 10, 100, 1); + pad.setButtons(3); pad.setButtons(1); + assertEquals(List.of("button 273 true"), events); + pad.setButtons(0); + assertEquals(List.of("button 273 true", "button 273 false"), events); + } + @Test public void resettingOverlappingRightButtonOwnersReleasesOnlyOnce() { + pad.frame(3, 10, 10, 100, 1); pad.setButtons(3); pad.reset(); pad.reset(); + assertEquals(List.of("button 273 true", "button 273 false"), events); + } + @Test public void ordinaryPrimaryAndSecondaryRemainIndependent() { + pad.frame(1, 10, 10, 100, 1); pad.setButtons(3); pad.setButtons(2); pad.setButtons(0); + assertEquals(List.of("button 272 true", "button 273 true", "button 272 false", "button 273 false"), events); + } +} From 05738fc3c153f601798e9081966df08dd11102c5 Mon Sep 17 00:00:00 2001 From: suxton Date: Tue, 10 Nov 2026 00:10:51 +0800 Subject: [PATCH 2/5] Add on-device regression probes for pointer capture --- .../validation/CaptureRegressionProbe.java | 65 ++++++++++ scripts/validation/StandardCaptureProbe.java | 113 ++++++++++++++++++ scripts/validation/check_standard_capture.py | 81 +++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 scripts/validation/CaptureRegressionProbe.java create mode 100644 scripts/validation/StandardCaptureProbe.java create mode 100644 scripts/validation/check_standard_capture.py diff --git a/scripts/validation/CaptureRegressionProbe.java b/scripts/validation/CaptureRegressionProbe.java new file mode 100644 index 000000000..1fd887912 --- /dev/null +++ b/scripts/validation/CaptureRegressionProbe.java @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import android.content.Context; +import android.os.Looper; +import android.view.InputDevice; +import android.view.MotionEvent; +import cn.classfun.droidvm.ui.vm.display.base.*; +import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.DirectInputSink; +import java.lang.reflect.*; +import java.nio.*; +import java.util.*; +import java.util.concurrent.*; + +public class CaptureRegressionProbe { + static Field field(String name)throws Exception {Field f=StandardPointerCapture.class.getDeclaredField(name);f.setAccessible(true);return f;} + static void lifecycle()throws Exception { + if(Looper.myLooper()==null)Looper.prepareMainLooper(); + Class at=Class.forName("android.app.ActivityThread");Object thread=at.getMethod("systemMain").invoke(null); + Context context=(Context)at.getMethod("getSystemContext").invoke(thread); + List guest=new ArrayList<>(); + DirectInputSink sink=new DirectInputSink("vm",()->"gpu-0",null,(channel,data)->{ + ByteBuffer b=ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + while(b.remaining()>=8){int type=b.getShort(),code=b.getShort(),value=b.getInt();if(type==1&&code==272)guest.add(value);}return true; + }); + StandardPointerCapture capture=new StandardPointerCapture(new CapturedPointerLayout(context,null)); + capture.setSink(sink);field("active").set(capture,true); + MotionEvent.PointerProperties prop=new MotionEvent.PointerProperties();prop.id=0;prop.toolType=MotionEvent.TOOL_TYPE_MOUSE; + MotionEvent.PointerCoords coord=new MotionEvent.PointerCoords(); + MotionEvent event=MotionEvent.obtain(1,1,MotionEvent.ACTION_BUTTON_PRESS,1,new MotionEvent.PointerProperties[]{prop},new MotionEvent.PointerCoords[]{coord},0,1,1,1,999,0,InputDevice.SOURCE_MOUSE_RELATIVE,0); + capture.onEvent(event);event.recycle(); + ExecutorService worker=(ExecutorService)field("worker").get(capture); + worker.submit(()->{}).get(3,TimeUnit.SECONDS); + CountDownLatch entered=new CountDownLatch(1),resume=new CountDownLatch(1); + worker.submit(()->{entered.countDown();try{resume.await();}catch(InterruptedException e){throw new RuntimeException(e);}}); + if(!entered.await(3,TimeUnit.SECONDS))throw new AssertionError("worker not paused"); + capture.close(sink::close);resume.countDown(); + if(!worker.awaitTermination(3,TimeUnit.SECONDS))throw new AssertionError("worker not drained"); + System.out.println(fmt("Lifecycle actual guest button events=%s expected=[1, 0]", guest)); + if(!guest.equals(List.of(1,0)))throw new AssertionError("release was dropped"); + if(sink.write(2,new byte[0]))throw new AssertionError("sink was not closed after drain"); + } + static void wheel(){ + ArrayDeque work=new ArrayDeque<>();int[] position={0}; + PointerEventQueue q=new PointerEventQueue(work::addLast,new PointerEventQueue.Output(){ + public void move(int x,int y){}public void button(int code,boolean down){} + public void scroll(int v,int h){position[0]=Math.max(0,position[0]-v);} + }); + q.scroll(1,0);q.scroll(-1,0);while(!work.isEmpty())work.removeFirst().run(); + System.out.println(fmt("Wheel top-boundary position=%d expected=1", position[0])); + if(position[0]!=1)throw new AssertionError("wheel reversal was lost"); + } + static void chord(){ + boolean[] held={false};List raw=new ArrayList<>(); + TouchpadGestureRecognizer pad=new TouchpadGestureRecognizer(new PointerEventQueue.Output(){ + public void move(int x,int y){}public void scroll(int v,int h){} + public void button(int code,boolean down){raw.add(fmt("%d:%s", code, down));if(code==273)held[0]=down;} + },true); + pad.frame(3,10,10,100,1);pad.setButtons(3);pad.setButtons(2); + System.out.println(fmt("Raw chord secondary held=%s expected=true events=%s", held[0], raw)); + if(!held[0])throw new AssertionError("secondary released while still held"); + pad.setButtons(0);if(held[0])throw new AssertionError("final secondary release missing"); + if(!raw.equals(List.of("273:true","273:false")))throw new AssertionError(fmt("duplicate transitions %s", raw)); + } + public static void main(String[]args)throws Exception {lifecycle();wheel();chord();System.out.println("ALL 3 merge-review regression checks passed");} +} diff --git a/scripts/validation/StandardCaptureProbe.java b/scripts/validation/StandardCaptureProbe.java new file mode 100644 index 000000000..2e12320d0 --- /dev/null +++ b/scripts/validation/StandardCaptureProbe.java @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import android.content.Context; +import android.os.Looper; +import android.view.InputDevice; +import android.view.MotionEvent; +import cn.classfun.droidvm.ui.vm.display.base.*; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.*; +import java.util.concurrent.*; + +public class StandardCaptureProbe { + static StandardPointerCapture capture; + static List events=Collections.synchronizedList(new ArrayList<>()); + static int checks; + static void set(String name,Object value) throws Exception { + Field f=StandardPointerCapture.class.getDeclaredField(name);f.setAccessible(true);f.set(capture,value); + } + static void flush() throws Exception { + Field f=StandardPointerCapture.class.getDeclaredField("worker");f.setAccessible(true); + ((ExecutorService)f.get(capture)).submit(()->{}).get(5,TimeUnit.SECONDS); + } + static void expect(String label,String...expected) throws Exception { + flush();if(!events.equals(Arrays.asList(expected))) throw new AssertionError(fmt("%s %s expected %s", label, events, Arrays.toString(expected))); + events.clear();checks++;System.out.println(fmt("PASS %s", label)); + } + static MotionEvent event(int device,int source,int action,int buttons,int[] ids,float[] x,float[] y,float wheel) { + MotionEvent.PointerProperties[] p=new MotionEvent.PointerProperties[ids.length]; + MotionEvent.PointerCoords[] c=new MotionEvent.PointerCoords[ids.length]; + for(int i=0;i at=Class.forName("android.app.ActivityThread"); + Object thread=at.getMethod("systemMain").invoke(null); + Context context=(Context)at.getMethod("getSystemContext").invoke(thread); + capture=new StandardPointerCapture(new CapturedPointerLayout(context,null)); + capture.setSink((channel,data)->{ + if(channel!=2)throw new AssertionError("wrong channel"); + ByteBuffer b=ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + while(b.remaining()>=8){int type=b.getShort()&65535,code=b.getShort()&65535,value=b.getInt(); + if(type!=0)events.add(fmt("%d:%d:%d", type, code, value)); + }return true; + }); + set("active",true);set("natural",true);set("systemNatural",true); + try { + relative(900,1,0,0,0);expect("primary press","1:272:1"); + relative(901,0,0,0,0);expect("idle second device preserves held primary"); + capture.onInputDeviceRemoved(900);expect("disconnect releases held primary","1:272:0"); + staleButtonEvents(InputDevice.SOURCE_MOUSE_RELATIVE, 906); + expect("relative action button overrides stale state", "1:273:1", "1:273:0"); + staleButtonEvents(InputDevice.SOURCE_TOUCHPAD, 907); + expect("raw action button overrides stale state", "1:273:1", "1:273:0"); + MotionEvent history=event(902,InputDevice.SOURCE_MOUSE_RELATIVE,MotionEvent.ACTION_MOVE,0,new int[]{0},new float[]{1.25f},new float[]{-0.5f},0); + MotionEvent.PointerCoords point=new MotionEvent.PointerCoords();point.x=.75f;point.y=-.5f; + history.addBatch(190,new MotionEvent.PointerCoords[]{point},0);capture.onEvent(history);history.recycle(); + expect("history and fractional motion retained","2:0:2","2:1:-1"); + relative(903,0,0,0,.6f);relative(904,0,0,0,.6f);expect("wheel remainders isolated"); + relative(903,0,0,0,.6f);expect("fractional wheel accumulates","2:8:1"); + raw(MotionEvent.ACTION_DOWN,0,new int[]{4},new float[]{10},new float[]{10}); + raw(MotionEvent.ACTION_UP,0,new int[]{4},new float[]{10},new float[]{10}); + expect("Android UP includes the last pointer","1:272:1","1:272:0"); + raw(MotionEvent.ACTION_DOWN,0,new int[]{4},new float[]{10},new float[]{10}); + raw(MotionEvent.ACTION_POINTER_DOWN|(1<<8),0,new int[]{4,8},new float[]{10,20},new float[]{10,20}); + raw(MotionEvent.ACTION_MOVE,0,new int[]{8,4},new float[]{20,10},new float[]{20,10}); + raw(MotionEvent.ACTION_POINTER_UP|(1<<8),0,new int[]{8,4},new float[]{20,10},new float[]{20,10}); + raw(MotionEvent.ACTION_UP,0,new int[]{8},new float[]{20},new float[]{20}); + expect("staggered/reordered two-finger tap","1:273:1","1:273:0"); + raw(MotionEvent.ACTION_BUTTON_PRESS,1,new int[]{0},new float[]{0},new float[]{0}); + raw(MotionEvent.ACTION_BUTTON_RELEASE,0,new int[]{0},new float[]{0},new float[]{0}); + expect("button-only event does not invent contacts","1:272:1","1:272:0"); + raw(MotionEvent.ACTION_DOWN,1,new int[]{4},new float[]{10},new float[]{10}); + raw(MotionEvent.ACTION_CANCEL,0,new int[]{4},new float[]{10},new float[]{10}); + expect("cancel releases raw drag without tap","1:272:1","1:272:0"); + int touchpad=-1; + for(int id:InputDevice.getDeviceIds()){InputDevice d=InputDevice.getDevice(id);if(d!=null&&d.supportsSource(InputDevice.SOURCE_TOUCHPAD)){touchpad=id;break;}} + if(touchpad<0)throw new AssertionError("physical touchpad missing"); + set("natural",false);relative(touchpad,0,0,0,1); + expect("relative touchpad direction preference","2:8:-1"); + relative(905,0,0,0,1);expect("mouse wheel unaffected by touchpad preference","2:8:1"); + } finally {capture.close();} + System.out.println(fmt("ALL %d Android adapter checks passed", checks)); + } +} diff --git a/scripts/validation/check_standard_capture.py b/scripts/validation/check_standard_capture.py new file mode 100644 index 000000000..319ff5265 --- /dev/null +++ b/scripts/validation/check_standard_capture.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Run the production capture adapter against real Android MotionEvents over adb. + +This standalone process uses a recording sink and does not request capture, +inject hardware events, send input to the VM, or alter the running app. +""" +import argparse +from pathlib import Path +import subprocess +import zipfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--android-jar", type=Path, required=True) + parser.add_argument("--d8-jar", type=Path, required=True) + parser.add_argument("--adb", default="adb") + args = parser.parse_args() + repo = Path(__file__).resolve().parents[2] + output = repo / "build/validation/standard-capture" + source = output / "src" + classes = output / "classes" + classes.mkdir(parents=True, exist_ok=True) + # Only these unrelated dependencies are replaced. The view, adapter, queue, + # button ownership, gesture recognizer, and encoder are production sources. + stubs = { + "androidx/annotation/NonNull.java": + "package androidx.annotation; public @interface NonNull {}", + "androidx/annotation/Nullable.java": + "package androidx.annotation; public @interface Nullable {}", + "cn/classfun/droidvm/lib/store/vm/NativeDisplay.java": + "package cn.classfun.droidvm.lib.store.vm; " + "public class NativeDisplay { public static final int MOUSE=2,CHANNEL_COUNT=4; }", + "cn/classfun/droidvm/ui/vm/display/nativedisplay/input/InputForwarder.java": + "package cn.classfun.droidvm.ui.vm.display.nativedisplay.input; " + "public class InputForwarder { public interface InputSink { " + "boolean write(int channel,byte[] data); } }", + "cn/classfun/droidvm/lib/utils/StringUtils.java": + "package cn.classfun.droidvm.lib.utils; public class StringUtils { " + "public static String fmt(String s,Object...args){return String.format(s,args);} }", + "cn/classfun/droidvm/display/INativeDisplayRootService.java": + "package cn.classfun.droidvm.display; public interface INativeDisplayRootService { " + "boolean writeInput(String vm,String screen,int channel,byte[] data) throws Exception; }", + } + for relative, contents in stubs.items(): + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf8") + java = repo / "app/src/main/java/cn/classfun/droidvm/ui/vm/display" + files = list(source.rglob("*.java")) + files.extend(Path(__file__).parent.glob("*Probe.java")) + files.extend(java / "base" / name for name in ( + "CapturedPointerLayout.java", "StandardPointerCapture.java", + "PointerEventQueue.java", "PointerButtons.java", "TouchpadGestureRecognizer.java", + )) + files.append(java / "nativedisplay/input/EvdevEncoder.java") + files.append(java / "nativedisplay/input/DirectInputSink.java") + subprocess.run([ + "javac", "--release", "17", "-cp", str(args.android_jar), + "-d", str(classes), *map(str, files), + ], check=True) + jar = output / "classes.jar" + with zipfile.ZipFile(jar, "w") as archive: + for path in classes.rglob("*.class"): + archive.write(path, path.relative_to(classes)) + dex = output / "probe.zip" + subprocess.run([ + "java", "-cp", str(args.d8_jar), "com.android.tools.r8.D8", + "--lib", str(args.android_jar), "--min-api", "33", + "--output", str(dex), str(jar), + ], check=True) + remote = "/data/local/tmp/standard-capture-probe.zip" + subprocess.run([args.adb, "push", str(dex), remote], check=True) + for probe in ("StandardCaptureProbe", "CaptureRegressionProbe"): + subprocess.run([ + args.adb, "shell", f"CLASSPATH={remote} app_process /system/bin {probe}", + ], check=True) + + +if __name__ == "__main__": + main() From 017de5874230fa437325783056ff76b293581ae4 Mon Sep 17 00:00:00 2001 From: suxton Date: Wed, 11 Nov 2026 00:33:11 +0800 Subject: [PATCH 3/5] Normalize relative touchpad scroll before sending wheel steps --- .../droidvm/ui/vm/display/base/StandardPointerCapture.java | 7 +++++-- scripts/validation/StandardCaptureProbe.java | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java index 5c7fb47d5..94a893aee 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/StandardPointerCapture.java @@ -20,6 +20,8 @@ /** Android capture -> per-device translation -> ordered evdev frames -> existing VM sink. */ public final class StandardPointerCapture implements CapturedPointerLayout.Handler, InputManager.InputDeviceListener { private static final String TAG = "StandardPointerCapture"; + // Calibrate continuous relative touchpad deltas before emitting discrete guest wheel steps. + private static final float TOUCHPAD_SCROLL_UNITS_PER_NOTCH = 32f; private final CapturedPointerLayout view; private final InputManager inputManager; private final Handler main = new Handler(Looper.getMainLooper()); @@ -223,12 +225,13 @@ void wheel(MotionEvent event) { // A normal mouse wheel must never inherit the touchpad preference. float sign = !touchpad ? 1 : raw ? (natural ? -1 : 1) : natural == systemNatural ? 1 : -1; + float scale = touchpad && !raw ? sign / TOUCHPAD_SCROLL_UNITS_PER_NOTCH : sign; for (int h = 0; h <= event.getHistorySize(); h++) { boolean current = h == event.getHistorySize(); wheelV += (current ? event.getAxisValue(MotionEvent.AXIS_VSCROLL) - : event.getHistoricalAxisValue(MotionEvent.AXIS_VSCROLL, h)) * sign; + : event.getHistoricalAxisValue(MotionEvent.AXIS_VSCROLL, h)) * scale; wheelH += (current ? event.getAxisValue(MotionEvent.AXIS_HSCROLL) - : event.getHistoricalAxisValue(MotionEvent.AXIS_HSCROLL, h)) * sign; + : event.getHistoricalAxisValue(MotionEvent.AXIS_HSCROLL, h)) * scale; } int v = (int) wheelV, h = (int) wheelH; wheelV -= v; wheelH -= h; diff --git a/scripts/validation/StandardCaptureProbe.java b/scripts/validation/StandardCaptureProbe.java index 2e12320d0..071449234 100644 --- a/scripts/validation/StandardCaptureProbe.java +++ b/scripts/validation/StandardCaptureProbe.java @@ -104,7 +104,11 @@ public static void main(String[]args) throws Exception { int touchpad=-1; for(int id:InputDevice.getDeviceIds()){InputDevice d=InputDevice.getDevice(id);if(d!=null&&d.supportsSource(InputDevice.SOURCE_TOUCHPAD)){touchpad=id;break;}} if(touchpad<0)throw new AssertionError("physical touchpad missing"); - set("natural",false);relative(touchpad,0,0,0,1); + relative(touchpad,0,0,0,16); + expect("half a touchpad notch stays buffered"); + relative(touchpad,0,0,0,16); + expect("two half touchpad notches accumulate","2:8:1"); + set("natural",false);relative(touchpad,0,0,0,32); expect("relative touchpad direction preference","2:8:-1"); relative(905,0,0,0,1);expect("mouse wheel unaffected by touchpad preference","2:8:1"); } finally {capture.close();} From ecc4cd6783611cc0c4a0f6d68c2e14435f6dc378 Mon Sep 17 00:00:00 2001 From: suxton Date: Wed, 11 Nov 2026 01:21:14 +0800 Subject: [PATCH 4/5] Show a touchpad icon for pointer capture --- app/src/main/res/menu/menu_native_display_menu.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/menu/menu_native_display_menu.xml b/app/src/main/res/menu/menu_native_display_menu.xml index f1fbf251d..116f95710 100644 --- a/app/src/main/res/menu/menu_native_display_menu.xml +++ b/app/src/main/res/menu/menu_native_display_menu.xml @@ -14,6 +14,7 @@ Date: Wed, 11 Nov 2026 04:31:01 +0800 Subject: [PATCH 5/5] Remove the experimental label from pointer capture --- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0cc9dacc9..498c2b142 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1735,6 +1735,6 @@ 🔒 %1$s يبقى %1$s مع المضيف: تعذر على أحد برامج التشغيل تحريره، لذا لن يتحول إلى خامل إلا عند فصله - التقاط الفأرة أو لوحة اللمس (تجريبي) + التقاط الفأرة أو لوحة اللمس التمرير الطبيعي للوحة اللمس diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cb1235102..f6d86ea5c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1671,6 +1671,6 @@ 设备没有移动 被其他程序占用 %1$s 留在主机:有驱动不肯卸载,要拔掉重插才会回到 Idle - 捕获鼠标 / 触控板(实验) + 捕获鼠标 / 触控板 触控板自然滚动 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2c83ad051..6bd2a7e4c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1671,6 +1671,6 @@ 裝置沒有移動 被其他程式占用 %1$s 留在主機:有驅動不肯卸載,要拔掉重插才會回到 Idle - 擷取滑鼠 / 觸控板(實驗) + 擷取滑鼠 / 觸控板 觸控板自然捲動 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99a2d78b5..dc28c8a24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1729,6 +1729,6 @@ 🔒 %1$s %1$s stays with the host: a driver of its would not let go, so it goes idle only when it is unplugged - Capture mouse / touchpad (experimental) + Capture mouse / touchpad Natural touchpad scrolling