diff --git a/app/src/main/assets/linuxfs/usr/local/lib/libwninput.so b/app/src/main/assets/linuxfs/usr/local/lib/libwninput.so index ce29ca778..a179e8f0d 100755 Binary files a/app/src/main/assets/linuxfs/usr/local/lib/libwninput.so and b/app/src/main/assets/linuxfs/usr/local/lib/libwninput.so differ diff --git a/app/src/main/cpp/proot/src/cli/cli.c b/app/src/main/cpp/proot/src/cli/cli.c index 71a17648e..9c967ccc2 100644 --- a/app/src/main/cpp/proot/src/cli/cli.c +++ b/app/src/main/cpp/proot/src/cli/cli.c @@ -385,7 +385,7 @@ int main(int argc, char *const argv[]) { tracee = get_tracee(NULL, 0, true); if (tracee == NULL) goto error; - tracee->pid = getpid(); + set_tracee_pid(tracee, getpid()); /* Set verboseness from env variable, may be overriden by option */ { diff --git a/app/src/main/cpp/proot/src/path/canon.c b/app/src/main/cpp/proot/src/path/canon.c index 08c6fe6fc..c17756d2e 100644 --- a/app/src/main/cpp/proot/src/path/canon.c +++ b/app/src/main/cpp/proot/src/path/canon.c @@ -22,6 +22,7 @@ #include /* assert(3), */ #include /* E*, */ +#include /* open(2), O_*, */ #include /* PATH_MAX, */ #include /* sscanf(3), */ #include /* string(3), */ @@ -153,6 +154,98 @@ static inline int substitute_binding_stat(Tracee *tracee, Finality finality, return (S_ISLNK(statl.st_mode) ? 1 : 0); } +/* Below this many directories, lstat(2)-ing each costs less than + * resolve_parent_at_once(). */ +#define RESOLVE_AT_ONCE_MIN_DEPTH 3 + +/** + * Canonicalize at once the directory holding the last component of + * the absolute @user_path, instead of with an lstat(2) per component: + * the kernel resolves it, and it is canonical if the kernel's path is + * the one PRoot would build, ie. no link on the way. Only for a + * lexically clean path under the rootfs binding alone. This function + * returns the offset of the last component in @user_path and puts the + * directory in @guest_path, or returns 0 when the component-wise walk + * has to be used. + */ +static size_t resolve_parent_at_once(Tracee *tracee, const char *user_path, + char guest_path[PATH_MAX]) { + char parent[PATH_MAX]; + char expected[PATH_MAX]; + char resolved[PATH_MAX]; + char link[32]; + const Binding *binding; + const char *last; + const char *cursor; + size_t parent_length; + size_t prefix_length; + size_t expected_length; + ssize_t length; + int depth; + int fd; + + if (tracee->glue_type != 0) + return 0; + + last = strrchr(user_path, '/'); + if (last == NULL || last == user_path || last[1] == '\0' || + strcmp(last + 1, ".") == 0 || strcmp(last + 1, "..") == 0) + return 0; + + parent_length = last - user_path; + if (parent_length >= PATH_MAX) + return 0; + + /* No empty, "." or ".." component. */ + depth = 0; + for (cursor = user_path; cursor < last;) { + const char *end; + + end = memchr(cursor + 1, '/', last - cursor); + if (end == NULL) + end = last; + if (end == cursor + 1 || (end == cursor + 2 && cursor[1] == '.') || + (end == cursor + 3 && cursor[1] == '.' && cursor[2] == '.')) + return 0; + depth++; + cursor = end; + } + if (depth < RESOLVE_AT_ONCE_MIN_DEPTH) + return 0; + + memcpy(parent, user_path, parent_length); + parent[parent_length] = '\0'; + + /* Bindings are ordered deepest first, so the rootfs binding + * matching means no other binding is on the way. */ + binding = get_binding(tracee, GUEST, parent); + if (binding == NULL || binding->guest.length != 1) + return 0; + + /* A rootfs at "/" adds no prefix. */ + prefix_length = (binding->host.length == 1 ? 0 : binding->host.length); + expected_length = prefix_length + parent_length; + if (expected_length >= PATH_MAX) + return 0; + memcpy(expected, binding->host.path, prefix_length); + strcpy(expected + prefix_length, parent); + + fd = open(expected, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) + return 0; + + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + length = readlink(link, resolved, sizeof(resolved)); + close(fd); + + if (length < 0 || (size_t)length != expected_length || + memcmp(resolved, expected, expected_length) != 0) + return 0; + + strcpy(guest_path, parent); + return last + 1 - user_path; +} + /** * Copy in @guest_path the canonicalization (see `man 3 realpath`) of * @user_path regarding to @tracee->root. The path to canonicalize @@ -169,6 +262,7 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final, char host_path[PATH_MAX]; Finality finality; const char *cursor; + size_t offset; int status; /* Avoid infinite loop on circular links. */ @@ -183,15 +277,20 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final, } else strcpy(guest_path, "/"); - /* Resolve bindings for the initial '/' component or user_path, - * which is not handled in the loop below. - * In particular HOST_PATH extensions are called from there. */ - status = substitute_binding_stat(tracee, NOT_FINAL, guest_path, host_path); - if (status < 0) - return status; + offset = (user_path[0] == '/' + ? resolve_parent_at_once(tracee, user_path, guest_path) + : 0); + if (offset == 0) { + /* Resolve bindings for the initial '/' component or user_path, + * which is not handled in the loop below. + * In particular HOST_PATH extensions are called from there. */ + status = substitute_binding_stat(tracee, NOT_FINAL, guest_path, host_path); + if (status < 0) + return status; + } /* Canonicalize recursely 'user_path' into 'guest_path'. */ - cursor = user_path; + cursor = user_path + offset; finality = NOT_FINAL; while (!IS_FINAL(finality)) { Comparison comparison; @@ -217,6 +316,20 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final, join_paths(scratch_path, guest_path, component); + /* A final component that is not dereferenced needs no + * lstat(2): whether it is a link only matters to follow + * it. Glue is still built from that lstat(2). */ + if (finality == FINAL_NORMAL && !deref_final && tracee->glue_type == 0) { + strcpy(host_path, scratch_path); + status = substitute_binding(tracee, GUEST, host_path); + if (status < 0) + return status; + + strcpy(scratch_path, guest_path); + join_paths(guest_path, scratch_path, component); + continue; + } + /* Resolve bindings and check that a non-final * component exists and either is a directory or is a * symlink. For this latter case, we check that the diff --git a/app/src/main/cpp/proot/src/syscall/sysnums-arm.h b/app/src/main/cpp/proot/src/syscall/sysnums-arm.h index 1cf64c4f6..95a8efbab 100644 --- a/app/src/main/cpp/proot/src/syscall/sysnums-arm.h +++ b/app/src/main/cpp/proot/src/syscall/sysnums-arm.h @@ -356,5 +356,6 @@ static const Sysnum sysnums_arm[] = { [395] = PR_pkey_alloc, [396] = PR_pkey_free, [397] = PR_statx, + [435] = PR_clone3, [439] = PR_faccessat2, }; diff --git a/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h b/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h index a73583e24..b04a2e55b 100644 --- a/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h +++ b/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h @@ -278,5 +278,6 @@ static const Sysnum sysnums_arm64[] = { [289] = PR_pkey_alloc, [290] = PR_pkey_free, [291] = PR_statx, + [435] = PR_clone3, [439] = PR_faccessat2, }; diff --git a/app/src/main/cpp/proot/src/syscall/sysnums.list b/app/src/main/cpp/proot/src/syscall/sysnums.list index 1010504e9..1651352a3 100644 --- a/app/src/main/cpp/proot/src/syscall/sysnums.list +++ b/app/src/main/cpp/proot/src/syscall/sysnums.list @@ -39,6 +39,7 @@ SYSNUM(clock_gettime) SYSNUM(clock_nanosleep) SYSNUM(clock_settime) SYSNUM(clone) +SYSNUM(clone3) SYSNUM(close) SYSNUM(connect) SYSNUM(copy_file_range) diff --git a/app/src/main/cpp/proot/src/tracee/event.c b/app/src/main/cpp/proot/src/tracee/event.c index d5041c3ce..2e7141a9d 100644 --- a/app/src/main/cpp/proot/src/tracee/event.c +++ b/app/src/main/cpp/proot/src/tracee/event.c @@ -101,7 +101,7 @@ int launch_process(Tracee *tracee, char *const argv[]) { default: /* parent */ /* We know the pid of the first tracee now. */ - tracee->pid = pid; + set_tracee_pid(tracee, pid); return 0; } diff --git a/app/src/main/cpp/proot/src/tracee/tracee.c b/app/src/main/cpp/proot/src/tracee/tracee.c index e93be3eef..8e35d23ef 100644 --- a/app/src/main/cpp/proot/src/tracee/tracee.c +++ b/app/src/main/cpp/proot/src/tracee/tracee.c @@ -40,6 +40,7 @@ #include "ptrace/wait.h" #include "syscall/sysnum.h" #include "tracee/event.h" +#include "tracee/mem.h" #include "tracee/reg.h" #include "tracee/tracee.h" @@ -47,6 +48,18 @@ static Tracees tracees; +/* Tracees by pid: every event looks its tracee up, and a session has + * a tracee per thread. */ +#define TRACEE_BUCKETS 1024 +static Tracees buckets[TRACEE_BUCKETS]; + +/* Whether a tracee was terminated since the last sweep. */ +static bool terminations_pending = false; + +static Tracees *bucket_of(pid_t pid) { + return &buckets[(unsigned int)pid % TRACEE_BUCKETS]; +} + /** * Remove @zombie from its parent's list of zombies. Note: this is a * talloc destructor. @@ -85,6 +98,7 @@ static int remove_tracee(Tracee *tracee) { int event; LIST_REMOVE(tracee, link); + LIST_REMOVE(tracee, bucket_link); /* Clean objects that are linked to this tracee's life * span. */ @@ -217,6 +231,7 @@ static Tracee *new_tracee(pid_t pid) { tracee->pid = pid; LIST_INSERT_HEAD(&tracees, tracee, link); + LIST_INSERT_HEAD(bucket_of(pid), tracee, bucket_link); tracee->life_context = talloc_new(tracee); @@ -314,7 +329,7 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create) { if (current_tracee != NULL && current_tracee->pid == pid) return (Tracee *)current_tracee; - LIST_FOREACH(tracee, &tracees, link) { + LIST_FOREACH(tracee, bucket_of(pid), bucket_link) { if (tracee->pid == pid) { /* Flush then allocate a new memory collector. */ TALLOC_FREE(tracee->ctx); @@ -327,11 +342,21 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create) { return (create ? new_tracee(pid) : NULL); } +/** + * Change the pid of @tracee, which get_tracee() looks it up by. + */ +void set_tracee_pid(Tracee *tracee, pid_t pid) { + tracee->pid = pid; + LIST_REMOVE(tracee, bucket_link); + LIST_INSERT_HEAD(bucket_of(pid), tracee, bucket_link); +} + /** * Mark tracee as terminated and optionally take action. */ void terminate_tracee(Tracee *tracee) { tracee->terminated = true; + terminations_pending = true; /* Case where the terminated tracee is marked to kill all tracees on exit. @@ -348,6 +373,10 @@ void terminate_tracee(Tracee *tracee) { void free_terminated_tracees() { Tracee *next; + if (!terminations_pending) + return; + terminations_pending = false; + /* Items can't be deleted when using LIST_FOREACH. */ next = tracees.lh_first; while (next != NULL) { @@ -383,6 +412,14 @@ int new_child(Tracee *parent, word_t clone_flags) { status = fetch_regs(parent); if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone) clone_flags = peek_reg(parent, CURRENT, SYSARG_1); + else if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone3) { + /* clone3(2) passes the usual flags as the first word of + * its struct clone_args. glibc 2.34 and later create + * every thread with it. */ + word_t flags = peek_word(parent, peek_reg(parent, CURRENT, SYSARG_1)); + if (errno == 0) + clone_flags = flags; + } /* Get the pid of the parent's new child. */ status = ptrace(PTRACE_GETEVENTMSG, parent->pid, NULL, &pid); diff --git a/app/src/main/cpp/proot/src/tracee/tracee.h b/app/src/main/cpp/proot/src/tracee/tracee.h index 9f1f9cb38..4012a01da 100644 --- a/app/src/main/cpp/proot/src/tracee/tracee.h +++ b/app/src/main/cpp/proot/src/tracee/tracee.h @@ -79,6 +79,9 @@ typedef struct tracee { /* Link for the list of all tracees. */ LIST_ENTRY(tracee) link; + /* Link for the tracees whose pid hashes alike. */ + LIST_ENTRY(tracee) bucket_link; + /* Process identifier. */ pid_t pid; @@ -257,6 +260,7 @@ typedef struct tracee { #define TRACEE(a) talloc_get_type_abort(talloc_parent(talloc_parent(a)), Tracee) extern Tracee *get_tracee(const Tracee *tracee, pid_t pid, bool create); +extern void set_tracee_pid(Tracee *tracee, pid_t pid); extern Tracee *get_stopped_ptracee(const Tracee *ptracer, pid_t pid, bool only_with_pevent, word_t wait_options); extern bool has_ptracees(const Tracee *ptracer, pid_t pid, word_t wait_options); diff --git a/app/src/main/cpp/winlator/fakeinput.cpp b/app/src/main/cpp/winlator/fakeinput.cpp index d09365167..ea54a96e0 100644 --- a/app/src/main/cpp/winlator/fakeinput.cpp +++ b/app/src/main/cpp/winlator/fakeinput.cpp @@ -1152,6 +1152,10 @@ EXPORT int close(int fd) { return my_close(fd); } +// Nothing wakes a reader when the app publishes, so a waiting reader looks at the ring again +// at least this often: it bounds how late a press reaches a game that blocks on the device. +static constexpr int kMaxRingWaitMs = 2; + EXPORT ssize_t read(int fd, void *buf, size_t count) { std::unique_lock guard(controller_mutex); auto controller = controller_map.find(fd); @@ -1244,7 +1248,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { int result = nanosleep(&sleep_time, nullptr); guard.lock(); if (result < 0) return -1; - if (backoff_ns < 16 * 1000 * 1000) backoff_ns *= 2; + if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2; } } @@ -1390,7 +1394,7 @@ static int poll_fake(struct pollfd *fds, nfds_t nfds, int timeout, if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms) return 0; - if (backoff_ms < 16) + if (backoff_ms < kMaxRingWaitMs) backoff_ms *= 2; } } @@ -1565,7 +1569,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds, if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms) return 0; - if (backoff_ms < 16) + if (backoff_ms < kMaxRingWaitMs) backoff_ms *= 2; } } diff --git a/app/src/main/runtime/container/ContainerCreation.kt b/app/src/main/runtime/container/ContainerCreation.kt index 49b821a90..991fe4f63 100644 --- a/app/src/main/runtime/container/ContainerCreation.kt +++ b/app/src/main/runtime/container/ContainerCreation.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Handler import android.os.Looper import com.winlator.cmod.feature.library.LinuxApps +import com.winlator.cmod.runtime.audio.directaudio.DirectAudioDriver import com.winlator.cmod.runtime.compat.box64.Box64Preset import com.winlator.cmod.runtime.compat.fexcore.FEXCorePreset import com.winlator.cmod.runtime.content.ContentProfile @@ -296,12 +297,14 @@ object ContainerCreation { runtime: ContentProfile?, ): Container? { val name = uniqueName(containerManager, GAMESCOPE_CONTAINER_NAME) + val wineVersion = runtime?.let { ContentsManager.getEntryName(it) } ?: WineInfo.MAIN_WINE_VERSION.identifier() + // Starts on DirectAudio; the container's settings can change it later. + val data = buildLaunchReadyData(context, contentsManager, name, wineVersion) + .put("audioDriver", DirectAudioDriver.IDENTIFIER) val created = if (runtime != null) { - val data = buildLaunchReadyData(context, contentsManager, name, ContentsManager.getEntryName(runtime)) containerManager.createContainer(data, contentsManager) } else { - val data = buildLaunchReadyData(context, contentsManager, name, WineInfo.MAIN_WINE_VERSION.identifier()) containerManager.createPrefixlessContainer(data) } val container = created ?: return null diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 00461f0a6..681ef27f5 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -344,7 +344,7 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity private boolean isVolumeDownPressed = false; private boolean guideHoldPending = false; private long guideMenuOpenedAt = 0L; - private static final long GUIDE_HOLD_OPEN_MS = 450L; + private static final long GUIDE_HOLD_OPEN_MS = 2000L; private static final long GUIDE_HOLD_TAIL_MS = 1200L; private final Runnable guideHoldOpenRunnable = new Runnable() { @Override @@ -473,6 +473,8 @@ public boolean isInputSuspended() { return isPaused; } + public boolean isGamescopeMode() { return gamescopeMode; } + private boolean isAnyControllerConnected() { if (winHandler != null && winHandler.hasSdlPads()) return true; for (int id : android.view.InputDevice.getDeviceIds()) { @@ -3257,7 +3259,7 @@ private void handleCapturedPointer(MotionEvent event) { boolean handled = false; int actionButton = event.getActionButton(); - switch (event.getAction()) { + switch (event.getActionMasked()) { case MotionEvent.ACTION_BUTTON_PRESS: if (actionButton == MotionEvent.BUTTON_PRIMARY) { xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT); @@ -3291,19 +3293,23 @@ private void handleCapturedPointer(MotionEvent event) { handled = true; break; case MotionEvent.ACTION_SCROLL: - float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL); - if (scrollY <= -1.0f) { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_DOWN); - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_DOWN); - } else if (scrollY >= 1.0f) { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_UP); - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_UP); - } + touchpadView.onMouseWheel(event.getAxisValue(MotionEvent.AXIS_VSCROLL)); handled = true; break; + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + capturedTouchpadX = Float.NaN; + break; } } + /** Where a captured touchpad's first finger was: touchpads report positions, not motion. */ + private float capturedTouchpadX = Float.NaN; + private float capturedTouchpadY = Float.NaN; + private int[] getCapturedPointerDelta(MotionEvent event) { // Sum batched samples; skipping history drops movement at low refresh rates. final int historySize = event.getHistorySize(); @@ -3315,7 +3321,14 @@ private int[] getCapturedPointerDelta(MotionEvent event) { } dx += event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); dy += event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); - if (dx == 0.0f && dy == 0.0f) { + if (event.isFromSource(InputDevice.SOURCE_TOUCHPAD)) { + if (dx == 0.0f && dy == 0.0f && !Float.isNaN(capturedTouchpadX)) { + dx = event.getX() - capturedTouchpadX; + dy = event.getY() - capturedTouchpadY; + } + capturedTouchpadX = event.getX(); + capturedTouchpadY = event.getY(); + } else if (dx == 0.0f && dy == 0.0f) { for (int i = 0; i < historySize; i++) { dx += event.getHistoricalX(i); dy += event.getHistoricalY(i); @@ -7400,6 +7413,21 @@ private boolean requiresDisplayReady(int itemId) { } } + /** Sticks, triggers, hats and captured mice/touchpads: held back to the next frame, they arrive up to a frame late. */ + private static final int UNBUFFERED_INPUT_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_TRACKBALL | InputDevice.SOURCE_CLASS_POSITION; + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return; + View decor = getWindow().getDecorView(); + decor.requestUnbufferedDispatch(UNBUFFERED_INPUT_SOURCES); + // A focus change recomputes the window's request from the focused view, dropping this one. + decor.getViewTreeObserver().addOnGlobalFocusChangeListener((oldFocus, newFocus) -> + decor.post(() -> decor.requestUnbufferedDispatch(UNBUFFERED_INPUT_SOURCES))); + } + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); @@ -10632,8 +10660,10 @@ && consumeControllerTestMotionEvent(event)) { @Override public boolean dispatchKeyEvent(KeyEvent event) { if (isSteamControllerShadowEvent(event.getDevice())) return true; + // A held guide button repeats; only a fresh press may close the menu it opened. + boolean freshKey = event.getKeyCode() != KeyEvent.KEYCODE_BUTTON_MODE || event.getRepeatCount() == 0; if (ExternalController.isGameController(event.getDevice()) - && handleControllerMenuKey(event.getKeyCode(), event.getAction() == KeyEvent.ACTION_DOWN, event.getEventTime())) return true; + && handleControllerMenuKey(event.getKeyCode(), event.getAction() == KeyEvent.ACTION_DOWN && freshKey, event.getEventTime())) return true; if (controllerTestComposeView != null && com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.isActive() && consumeControllerTestKeyEvent(event)) { @@ -10674,8 +10704,8 @@ && consumeControllerTestKeyEvent(event)) { } if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_MODE) { - // Menu closed: hold the guide button to open (a quick tap does nothing). Timer-based, so a - // missed release can never leave it stuck. + // Menu closed: holding the guide button opens it; a shorter press reaches the guest as a + // tap on release. Timer-based, so a missed release can never leave it stuck. if (event.getAction() == KeyEvent.ACTION_DOWN) { if (!guideHoldPending) { guideHoldPending = true; @@ -10683,6 +10713,9 @@ && consumeControllerTestKeyEvent(event)) { handler.postDelayed(guideHoldOpenRunnable, GUIDE_HOLD_OPEN_MS); } } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (guideHoldPending && winHandler != null && ExternalController.isGameController(event.getDevice())) { + winHandler.tapGuide(event.getDeviceId()); + } guideHoldPending = false; handler.removeCallbacks(guideHoldOpenRunnable); } @@ -10781,6 +10814,9 @@ private void handleSteamMenuInput(ExternalController pad, int[] pressedKeyCodes) handler.postDelayed(guideHoldOpenRunnable, GUIDE_HOLD_OPEN_MS); } if (previous.contains(KeyEvent.KEYCODE_BUTTON_MODE) && !pressed.contains(KeyEvent.KEYCODE_BUTTON_MODE)) { + if (guideHoldPending && winHandler != null && isSteamControllerInputEnabled()) { + winHandler.tapGuide(pad.getDeviceId()); + } guideHoldPending = false; handler.removeCallbacks(guideHoldOpenRunnable); } diff --git a/app/src/main/runtime/display/winhandler/WinHandler.java b/app/src/main/runtime/display/winhandler/WinHandler.java index abefea429..e7026c0a9 100644 --- a/app/src/main/runtime/display/winhandler/WinHandler.java +++ b/app/src/main/runtime/display/winhandler/WinHandler.java @@ -711,11 +711,25 @@ public void sendGamepadState() { if (xServer != null && xServer.getRenderer() != null) xServer.getRenderer().requestRenderCoalesced(VulkanRenderer.WAKE_WINHANDLER); } + /** + * A GameScope guest learns of pads only as it starts (its udev monitor never reports one added + * later), so there no slot is ever removed: a disconnect or a move leaves it present and + * neutral, and whichever pad takes it next reaches the game through it. + */ + private boolean slotsOutliveDevices() { + return this.activity.isGamescopeMode(); + } + public void representVirtualGamepad() { Integer slot = this.deviceToSlot.get(OSC_DEVICE_ID); if (slot == null || slot < 0 || slot >= MAX_CONTROLLERS) { return; } + if (slotsOutliveDevices()) { + ensureWriterForSlot(slot); + if (this.writers[slot] != null) this.writers[slot].requestFullResend(); + return; + } if (this.writers[slot] != null) { this.writers[slot].destroy(); this.writers[slot] = null; @@ -827,6 +841,38 @@ private void writeVirtualGamepadState(boolean applyGyroOverlay, boolean allowHid } } + private static final long GUIDE_TAP_MS = 120; + private volatile Runnable pendingGuideRelease; + + /** A guide press released before the hold opens the menu: the guest gets a tap (Steam opens its menu). */ + public void tapGuide(int deviceId) { + if (Looper.myLooper() != Looper.getMainLooper()) { + this.inputHandler.post(() -> tapGuide(deviceId)); + return; + } + ExternalController controller = getController(deviceId); + if (controller == null) return; + // A tap still in flight ends first, so two quick taps reach the guest as two presses. + Runnable inFlight = this.pendingGuideRelease; + if (inFlight != null) { + this.inputHandler.removeCallbacks(inFlight); + inFlight.run(); + } + setGuidePressed(controller, true); + Runnable release = () -> { + pendingGuideRelease = null; + setGuidePressed(controller, false); + }; + pendingGuideRelease = release; + this.inputHandler.postDelayed(release, GUIDE_TAP_MS); + } + + private void setGuidePressed(ExternalController controller, boolean pressed) { + controller.state.setPressed(GamepadState.BUTTON_GUIDE, pressed); + controller.remappedState.setPressed(GamepadState.BUTTON_GUIDE, pressed); + sendGamepadState(controller); + } + public void sendGamepadState(ExternalController controller) { if (controller != null) { this.currentController = controller; @@ -988,7 +1034,7 @@ private boolean moveVirtualGamepadToSlot(int targetSlot, boolean releaseVacatedS ensureWriterForSlot(targetSlot); if (this.writers[currentSlot] != null) { - if (releaseVacatedSlot && !isPhysicalSlotOccupied(currentSlot)) { + if (releaseVacatedSlot && !isPhysicalSlotOccupied(currentSlot) && !slotsOutliveDevices()) { // The virtual pad is leaving this slot for good (consolidation, not a // hand-off to an incoming physical pad). Tear it down so winebus sees the // device disappear instead of a phantom stuck-at-neutral controller. @@ -1165,7 +1211,10 @@ private void releaseSlot(int deviceId) { if (this.fallbackSlot == slot) { this.fallbackSlot = -1; } - if (this.writers[slot] != null) { + if (this.writers[slot] != null && slotsOutliveDevices()) { + // Released to neutral and kept for the next pad; the guest would never find a new one. + this.writers[slot].reset(); + } else if (this.writers[slot] != null) { // Remove the discovery node so winebus sees a disconnect; event bytes live in // the slot ring and are not replayed by reopening this path. this.writers[slot].destroy(); @@ -1534,6 +1583,11 @@ private void releaseShadowedValveSlots() { public void closeFakeInputWriter() { cancelPendingVirtualGamepadRebalance(); + Runnable guideRelease = this.pendingGuideRelease; + if (guideRelease != null) { + this.inputHandler.removeCallbacks(guideRelease); + this.pendingGuideRelease = null; + } cancelAllPendingDeviceReleases(); if (this.inputManager != null && this.inputDeviceListener != null) { this.inputManager.unregisterInputDeviceListener(this.inputDeviceListener); @@ -1629,7 +1683,12 @@ public boolean onKeyEvent(KeyEvent event) { boolean handled = false; int deviceId = event.getDeviceId(); ExternalController controller = getController(deviceId); - if (controller != null && event.getRepeatCount() == 0) { + if (controller != null && event.getRepeatCount() > 0) { + // A held button repeats. Its state is already set; passed on, a repeat would move Android's + // focus (D-pad) or be treated as another key. Guide stays with the activity's hold timer. + return event.getKeyCode() != KeyEvent.KEYCODE_BUTTON_MODE; + } + if (controller != null) { int action = event.getAction(); if (action == 0 || action == 1) { handled = controller.updateStateFromKeyEvent(event); diff --git a/app/src/main/runtime/display/xserver/InputDeviceManager.java b/app/src/main/runtime/display/xserver/InputDeviceManager.java index 55db3439b..7e13133f3 100644 --- a/app/src/main/runtime/display/xserver/InputDeviceManager.java +++ b/app/src/main/runtime/display/xserver/InputDeviceManager.java @@ -157,6 +157,8 @@ public void sendEnterLeaveNotify(Window windowA, Window windowB, PointerWindowEv @Override public void onPointerButtonPress(Pointer.Button button) { if (xServer.isRelativeMouseMovement()) { + // A Wayland session's input sink already hands the button to the compositor. + if (xServer.hasInputSink()) return; WinHandler winHandler = xServer.getWinHandler(); int wheelDelta = button == Pointer.Button.BUTTON_SCROLL_UP @@ -198,8 +200,11 @@ public void onPointerButtonPress(Pointer.Button button) { @Override public void onPointerButtonRelease(Pointer.Button button) { if (xServer.isRelativeMouseMovement()) { - WinHandler winHandler = xServer.getWinHandler(); - winHandler.mouseEvent(MouseEventFlags.getFlagFor(button, false), 0, 0, 0); + if (xServer.hasInputSink()) return; + int flags = MouseEventFlags.getFlagFor(button, false); + // A wheel step is whole on press; its release carries nothing. + if (flags == 0 || flags == MouseEventFlags.WHEEL) return; + xServer.getWinHandler().mouseEvent(flags, 0, 0, 0); } else { Bitmask eventMask = createPointerEventMask(); Window grabWindow = xServer.grabManager.getWindow(); diff --git a/app/src/main/runtime/display/xserver/XServer.java b/app/src/main/runtime/display/xserver/XServer.java index a222c8989..7be074a2b 100644 --- a/app/src/main/runtime/display/xserver/XServer.java +++ b/app/src/main/runtime/display/xserver/XServer.java @@ -117,6 +117,10 @@ public void setInputSink(InputSink sink) { inputSink = sink; } + public boolean hasInputSink() { + return inputSink != null; + } + private void sinkPointerMove() { InputSink sink = inputSink; if (sink != null) sink.onPointerMove(pointer.getX(), pointer.getY()); diff --git a/app/src/main/runtime/input/controls/ExternalController.java b/app/src/main/runtime/input/controls/ExternalController.java index 6bdc9a44b..5deea6951 100644 --- a/app/src/main/runtime/input/controls/ExternalController.java +++ b/app/src/main/runtime/input/controls/ExternalController.java @@ -403,9 +403,25 @@ public boolean updateStateFromMotionEvent(MotionEvent event) { } public boolean updateStateFromKeyEvent(KeyEvent event) { - boolean z = false; boolean pressed = event.getAction() == 0; int keyCode = event.getKeyCode(); + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + this.state.dpad[0] = pressed; + return true; + case KeyEvent.KEYCODE_DPAD_RIGHT: + this.state.dpad[1] = pressed; + return true; + case KeyEvent.KEYCODE_DPAD_DOWN: + this.state.dpad[2] = pressed; + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + this.state.dpad[3] = pressed; + return true; + case KeyEvent.KEYCODE_BUTTON_MODE: + // Left to the activity: a tap goes to the guest on release, a hold opens the menu. + return false; + } int buttonIdx = getButtonIdxByKeyCode(keyCode); if (buttonIdx != -1) { if (buttonIdx == 10 || buttonIdx == 11) { @@ -427,32 +443,6 @@ public boolean updateStateFromKeyEvent(KeyEvent event) { this.state.setPressed(buttonIdx, pressed); return true; } - switch (keyCode) { - case 19: - this.state.dpad[0] = pressed && Math.abs(this.state.thumbLY) < 0.15f; - break; - case 20: - boolean[] zArr = this.state.dpad; - if (pressed && Math.abs(this.state.thumbLY) < 0.15f) { - z = true; - } - zArr[2] = z; - break; - case 21: - boolean[] zArr2 = this.state.dpad; - if (pressed && Math.abs(this.state.thumbLX) < 0.15f) { - z = true; - } - zArr2[3] = z; - break; - case 22: - boolean[] zArr3 = this.state.dpad; - if (pressed && Math.abs(this.state.thumbLX) < 0.15f) { - z = true; - } - zArr3[1] = z; - break; - } return true; } diff --git a/app/src/main/runtime/input/ui/InputControlsView.java b/app/src/main/runtime/input/ui/InputControlsView.java index fd03d6680..cc42e34f7 100644 --- a/app/src/main/runtime/input/ui/InputControlsView.java +++ b/app/src/main/runtime/input/ui/InputControlsView.java @@ -685,7 +685,7 @@ public int getMaxWidth() { @Override protected void onDetachedFromWindow() { - cancelContinuousMouseMove(); + cancelActiveTouches(); if (mouseMoveTimer != null) { mouseMoveTimer.cancel(); mouseMoveTimer = null; @@ -701,14 +701,17 @@ private void createMouseMoveTimer() { if (xServer == null) return; WinHandler winHandler = xServer.getWinHandler(); if (mouseMoveTimer == null && profile != null) { - final float cursorSpeed = profile.getCursorSpeed(); mouseMoveTimer = new Timer(); mouseMoveTimer.schedule( new TimerTask() { @Override public void run() { if (getContext() instanceof XServerDisplayActivity && ((XServerDisplayActivity)getContext()).isInputSuspended()) return; - if (mouseMoveOffsetX != 0 || mouseMoveOffsetY != 0) { int dx = (int) (mouseMoveOffsetX * cursorSpeed * 20); + ControlsProfile currentProfile = profile; + if (currentProfile == null) return; + if (mouseMoveOffsetX != 0 || mouseMoveOffsetY != 0) { + float cursorSpeed = currentProfile.getCursorSpeed(); + int dx = (int) (mouseMoveOffsetX * cursorSpeed * 20); int dy = (int) (mouseMoveOffsetY * cursorSpeed * 20); if (xServer.isRelativeMouseMovement()) { xServer.updatePointerForDisplayDelta(dx, dy); @@ -987,6 +990,8 @@ public boolean onTouchEvent(MotionEvent event) { } batchingUpdates = false; + // Held back to the next frame, a stick drag reaches the game up to a frame late. + if (eventHandled) requestUnbufferedDispatch(event); if (eventHandled || staleReleased) flushGamepadState(); syncCapturedPointers(); if (!eventHandled) dispatchUnhandledTouch(event); @@ -1065,6 +1070,7 @@ public boolean onTouchEvent(MotionEvent event) { batchingUpdates = false; WinHandler winHandler = xServer != null ? xServer.getWinHandler() : null; + if (anyControlHandled) requestUnbufferedDispatch(event); if (anyControlHandled && winHandler != null) { winHandler.sendGamepadState(); } diff --git a/app/src/main/runtime/input/ui/TouchpadView.kt b/app/src/main/runtime/input/ui/TouchpadView.kt index 3e3f7b683..1a9f35386 100644 --- a/app/src/main/runtime/input/ui/TouchpadView.kt +++ b/app/src/main/runtime/input/ui/TouchpadView.kt @@ -6,9 +6,11 @@ import android.graphics.drawable.ColorDrawable import android.graphics.drawable.StateListDrawable import android.os.Handler import android.os.Looper +import android.view.InputDevice import android.view.MotionEvent import android.view.PointerIcon import android.view.View +import android.view.ViewConfiguration import android.widget.FrameLayout import com.winlator.cmod.R import com.winlator.cmod.runtime.display.XServerDisplayActivity @@ -42,6 +44,7 @@ class TouchpadView( const val MODE_MAP_TO_RIGHT_STICK = 2 private const val TOUCHSCREEN_DOUBLE_TAP_MS = 500L private const val TOUCHSCREEN_DOUBLE_TAP_DISTANCE = 100f + private const val TOUCHSCREEN_PRESS_DELAY_MS = 50L } private var continueClick = true @@ -69,6 +72,26 @@ class TouchpadView( private var sinkTapTime = 0L private var sinkTapX = 0f private var sinkTapY = 0f + private var sinkTouchX = 0f + private var sinkTouchY = 0f + private var touchLeftPressed = false + private var touchPrimaryId = -1 + private var touchSecondId = -1 + private var twoFingerGesture = false + private var pressPending = false + private val pressDown = FloatArray(4) + private val pressCurrent = FloatArray(4) + private val pendingPressRunnable = Runnable { firePendingPress() } + private var twoFingerMoved = false + private var twoFingerLastY = 0f + private val twoFingerStart = FloatArray(4) + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private val twoFingerScrollStep = 40f * resources.displayMetrics.density + private var wheelAccum = 0f + private var mouseLastX = Float.NaN + private var mouseLastY = Float.NaN + private var mouseRemainderX = 0f + private var mouseRemainderY = 0f private var screenTouchMode = MODE_TRACKPAD private var rtsGesturesEnabled = false private val xform = XForm.getInstance() @@ -112,8 +135,7 @@ class TouchpadView( override fun onDetachedFromWindow() { super.onDetachedFromWindow() - rtsGestureEngine.releaseAll() - screenTouchStick.releaseAll() + resetInputState() } private fun updateXform(outerWidth: Int, outerHeight: Int, innerWidth: Int, innerHeight: Int) { @@ -182,6 +204,7 @@ class TouchpadView( if (!mouseEnabled) return true resetTouchscreenTimeout() if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) return handleStylusEvent(event) + if (event.isFromSource(InputDevice.SOURCE_MOUSE)) return handleMouseTouchEvent(event) val action = event.actionMasked if (action == MotionEvent.ACTION_DOWN || activeTouchHandler == null) { activeTouchHandler = selectTouchHandler() @@ -262,10 +285,13 @@ class TouchpadView( val actionIndex = event.actionIndex val pointerId = event.getPointerId(actionIndex) val actionMasked = event.actionMasked + if (actionMasked == MotionEvent.ACTION_CANCEL) { + releaseTouches() + return true + } if (actionMasked != MotionEvent.ACTION_MOVE && (pointerId >= MAX_FINGERS || pointerIdsToIgnore.contains(pointerId))) return true when (actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { - if (event.isFromSource(8194)) return true scrollAccumY = 0.0f scrolling = false fingers[pointerId] = Finger(event.getX(actionIndex), event.getY(actionIndex)) @@ -316,64 +342,120 @@ class TouchpadView( } } MotionEvent.ACTION_MOVE -> { - if (event.isFromSource(8194)) { - if (isInputSuspended) return true - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) - if (xServer.isRelativeMouseMovement) { - xServer.winHandler.mouseEvent(MouseEventFlags.MOVE, transformedPoint[0].toInt(), transformedPoint[1].toInt(), 0) - updateVisibleRelativeCursor(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } else { - xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } - } else { - for (i in 0 until 4) { - if (fingers[i] != null) { - if (pointerIdsToIgnore.contains(i)) { - fingers[i] = null - numFingers = (numFingers - 1).toByte() - continue - } - val pointerIndex = event.findPointerIndex(i) - if (pointerIndex >= 0) { - fingers[i]!!.update(event.getX(pointerIndex), event.getY(pointerIndex)) - handleFingerMove(fingers[i]!!) - } else { - handleFingerUp(fingers[i]!!) - fingers[i] = null - numFingers = (numFingers - 1).toByte() - } + for (i in 0 until 4) { + if (fingers[i] != null) { + val pointerIndex = event.findPointerIndex(i) + if (pointerIndex >= 0) { + fingers[i]!!.update(event.getX(pointerIndex), event.getY(pointerIndex)) + handleFingerMove(fingers[i]!!) + } else { + handleFingerUp(fingers[i]!!) + fingers[i] = null + numFingers = (numFingers - 1).toByte() } } } } - MotionEvent.ACTION_CANCEL -> { - longPressHandler.removeCallbacks(longPressRunnable) - longPressActive = false - for (i in 0 until 4) fingers[i] = null - numFingers = 0 - } } return true } + /** A mouse the app doesn't capture: its buttons arrive through [onExternalMouseEvent], its drags come here. */ + private fun handleMouseTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_MOVE && !isInputSuspended) moveExternalMouse(event) + return true + } + + private fun moveExternalMouse(event: MotionEvent) { + val transformedPoint = XForm.transformPoint(xform, event.x, event.y) + if (xServer.isRelativeMouseMovement) { + var dx = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) + var dy = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) + for (i in 0 until event.historySize) { + dx += event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_X, i) + dy += event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_Y, i) + } + if (dx == 0f && dy == 0f && !mouseLastX.isNaN()) { + dx = event.x - mouseLastX + dy = event.y - mouseLastY + } + val delta = computeDeltaPoint(0f, 0f, dx, dy) + mouseRemainderX += delta[0] + mouseRemainderY += delta[1] + val moveX = mouseRemainderX.toInt() + val moveY = mouseRemainderY.toInt() + mouseRemainderX -= moveX + mouseRemainderY -= moveY + if (moveX != 0 || moveY != 0) xServer.winHandler.mouseEvent(MouseEventFlags.MOVE, moveX, moveY, 0) + updateVisibleRelativeCursor(transformedPoint[0].toInt(), transformedPoint[1].toInt()) + } else { + xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) + } + mouseLastX = event.x + mouseLastY = event.y + } + + /** Wheel motion in notches; high-resolution wheels and touchpads send fractions of one. */ + fun onMouseWheel(amount: Float) { + if (amount == 0f || amount.isNaN()) return + if (wheelAccum != 0f && Math.signum(wheelAccum) != Math.signum(amount)) wheelAccum = 0f + wheelAccum += amount + while (wheelAccum >= 1f) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_UP) + wheelAccum -= 1f + } + while (wheelAccum <= -1f) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_DOWN) + wheelAccum += 1f + } + } + + private fun clickPointerButton(button: Pointer.Button) { + xServer.injectPointerButtonPress(button) + xServer.injectPointerButtonRelease(button) + } + + /** + * The first finger clicks and drags where it touches. A second finger ends that press: the + * pair then scrolls as it moves, and right-clicks if it lifts without having moved. + */ private fun handleTouchscreenEvent(event: MotionEvent): Boolean { if (isInputSuspended) return true - val action = event.actionMasked - val ignorePointerId = event.getPointerId(event.actionIndex) - if (action != MotionEvent.ACTION_MOVE && (ignorePointerId >= MAX_FINGERS || pointerIdsToIgnore.contains(ignorePointerId))) return true - when (action) { - 0, 5 -> { handleTouchDown(event); return true } - 1, 6 -> { if (event.pointerCount == 2) handleTwoFingerTap(event) else handleTouchUp(event); return true } - 2 -> { if (event.pointerCount == 2) handleTwoFingerScroll(event) else handleTouchMove(event); return true } - 3 -> { - if (sinkTouchActive) { - sinkTouchActive = false - touchscreenSink?.onTouch(2, event.rawX, event.rawY) + val index = event.actionIndex + val pointerId = event.getPointerId(index) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + if (pointerId >= MAX_FINGERS || pointerIdsToIgnore.contains(pointerId) || twoFingerGesture) return true + if (touchPrimaryId == -1) { + touchPrimaryId = pointerId + handleTouchDown(event, index) + } else if (touchSecondId == -1) { + touchSecondId = pointerId + startTwoFingerGesture(event) } - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_RIGHT) - return true } + MotionEvent.ACTION_MOVE -> { + if (twoFingerGesture) { + handleTwoFingerMove(event) + } else if (touchPrimaryId != -1) { + val primaryIndex = event.findPointerIndex(touchPrimaryId) + if (primaryIndex >= 0) handleTouchMove(event, primaryIndex) + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { + if (twoFingerGesture) { + if (pointerId == touchPrimaryId || pointerId == touchSecondId) { + if (!twoFingerMoved && tapToClickEnabled) clickPointerButton(Pointer.Button.BUTTON_RIGHT) + twoFingerMoved = true + if (pointerId == touchPrimaryId) touchPrimaryId = -1 else touchSecondId = -1 + if (touchPrimaryId == -1 && touchSecondId == -1) twoFingerGesture = false + } + } else if (pointerId == touchPrimaryId) { + handleTouchUp(event, index) + } + if (event.actionMasked == MotionEvent.ACTION_UP) releaseTouchscreenPointers() + } + MotionEvent.ACTION_CANCEL -> releaseTouches() } return true } @@ -389,86 +471,174 @@ class TouchpadView( var touchscreenSink: TouchscreenSink? = null - private fun handleTouchDown(event: MotionEvent) { - if (sinkTouchActive) return - if (event.pointerCount == 1 && tapToClickEnabled && !isInputSuspended && sinkTouchDown(event)) return - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) + private fun rawX(event: MotionEvent, index: Int): Float = event.getX(index) + event.rawX - event.x + + private fun rawY(event: MotionEvent, index: Int): Float = event.getY(index) + event.rawY - event.y + + private fun readTouch(event: MotionEvent, index: Int, into: FloatArray) { + into[0] = event.getX(index) + into[1] = event.getY(index) + into[2] = rawX(event, index) + into[3] = rawY(event, index) + } + + /** The press waits briefly so a second finger landing with the first starts a gesture, not a click. */ + private fun handleTouchDown(event: MotionEvent, index: Int) { + readTouch(event, index, pressDown) + pressDown.copyInto(pressCurrent) + pressPending = true + postDelayed(pendingPressRunnable, TOUCHSCREEN_PRESS_DELAY_MS) + } + + private fun firePendingPress() { + if (!pressPending) return + cancelPendingPress() + if (isInputSuspended) return + pressAt(pressDown[0], pressDown[1], pressDown[2], pressDown[3]) + if (!pressCurrent.contentEquals(pressDown)) moveTouch() + } + + private fun cancelPendingPress() { + pressPending = false + removeCallbacks(pendingPressRunnable) + } + + private fun pressAt(x: Float, y: Float, rawX: Float, rawY: Float) { + if (tapToClickEnabled && sinkTouchDown(rawX, rawY)) return + val transformedPoint = XForm.transformPoint(xform, x, y) var tx = transformedPoint[0].toInt() var ty = transformedPoint[1].toInt() - if (event.pointerCount == 1) { - val now = System.currentTimeMillis() - val near = Math.hypot((event.x - lastTapRawX).toDouble(), (event.y - lastTapRawY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE - if (now - lastTapDownTime < TOUCHSCREEN_DOUBLE_TAP_MS && near) { - tx = lastTapTransX - ty = lastTapTransY - } - lastTapDownTime = now - lastTapRawX = event.x - lastTapRawY = event.y - lastTapTransX = tx - lastTapTransY = ty - } - if (!isInputSuspended) { - xServer.injectPointerMove(tx, ty) - if (event.pointerCount == 1 && tapToClickEnabled) xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT) + val now = System.currentTimeMillis() + val near = Math.hypot((x - lastTapRawX).toDouble(), (y - lastTapRawY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE + if (now - lastTapDownTime < TOUCHSCREEN_DOUBLE_TAP_MS && near) { + tx = lastTapTransX + ty = lastTapTransY + } + lastTapDownTime = now + lastTapRawX = x + lastTapRawY = y + lastTapTransX = tx + lastTapTransY = ty + xServer.injectPointerMove(tx, ty) + if (tapToClickEnabled) { + xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT) + touchLeftPressed = true } } /** A second tap close to the first lands exactly on it, so a double tap is a double click. */ - private fun sinkTouchDown(event: MotionEvent): Boolean { + private fun sinkTouchDown(rawX: Float, rawY: Float): Boolean { val sink = touchscreenSink ?: return false val now = System.currentTimeMillis() - val near = Math.hypot((event.rawX - sinkTapX).toDouble(), (event.rawY - sinkTapY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE + val near = Math.hypot((rawX - sinkTapX).toDouble(), (rawY - sinkTapY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE if (now - sinkTapTime >= TOUCHSCREEN_DOUBLE_TAP_MS || !near) { - sinkTapX = event.rawX - sinkTapY = event.rawY + sinkTapX = rawX + sinkTapY = rawY } sinkTapTime = now + sinkTouchX = sinkTapX + sinkTouchY = sinkTapY sinkTouchActive = sink.onTouch(0, sinkTapX, sinkTapY) return sinkTouchActive } - private fun handleTouchMove(event: MotionEvent) { - if (isInputSuspended) return + private fun handleTouchMove(event: MotionEvent, index: Int) { + readTouch(event, index, pressCurrent) + if (pressPending) { + val travel = Math.hypot((pressCurrent[0] - pressDown[0]).toDouble(), (pressCurrent[1] - pressDown[1]).toDouble()) + if (travel > touchSlop) firePendingPress() + return + } + moveTouch() + } + + private fun moveTouch() { if (sinkTouchActive) { - touchscreenSink?.onTouch(1, event.rawX, event.rawY) + sinkTouchX = pressCurrent[2] + sinkTouchY = pressCurrent[3] + touchscreenSink?.onTouch(1, sinkTouchX, sinkTouchY) return } - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) + val transformedPoint = XForm.transformPoint(xform, pressCurrent[0], pressCurrent[1]) xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) } - private fun handleTouchUp(event: MotionEvent) { + private fun handleTouchUp(event: MotionEvent, index: Int) { + readTouch(event, index, pressCurrent) + firePendingPress() if (sinkTouchActive) { - sinkTouchActive = false - touchscreenSink?.onTouch(2, event.rawX, event.rawY) + sinkTouchX = pressCurrent[2] + sinkTouchY = pressCurrent[3] + } + releaseTouchscreenPointers() + } + + /** Ends (or never starts) the first finger's press; the pointer stays there for the pair's clicks. */ + private fun startTwoFingerGesture(event: MotionEvent) { + val pressed = !pressPending + endTouchscreenPress() + if (!pressed && touchscreenSink?.onTouch(1, pressCurrent[2], pressCurrent[3]) != true) { + val transformedPoint = XForm.transformPoint(xform, pressCurrent[0], pressCurrent[1]) + xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) + } + twoFingerGesture = true + twoFingerMoved = false + scrollAccumY = 0f + val first = event.findPointerIndex(touchPrimaryId) + val second = event.findPointerIndex(touchSecondId) + if (first < 0 || second < 0) { + twoFingerMoved = true return } - if (!isInputSuspended) xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) + twoFingerStart[0] = event.getX(first) + twoFingerStart[1] = event.getY(first) + twoFingerStart[2] = event.getX(second) + twoFingerStart[3] = event.getY(second) + twoFingerLastY = (twoFingerStart[1] + twoFingerStart[3]) * 0.5f } - private fun handleTwoFingerScroll(event: MotionEvent) { - if (isInputSuspended) return - val activeFingers = fingers.filterNotNull() - if (activeFingers.size < 2) return - val finger1 = activeFingers[0] - val finger2 = activeFingers[1] - val scrollDistance = finger1.y - finger2.y - if (Math.abs(scrollDistance) > 10) { - val button = if (scrollDistance > 0) Pointer.Button.BUTTON_SCROLL_UP else Pointer.Button.BUTTON_SCROLL_DOWN - xServer.injectPointerButtonPress(button) - xServer.injectPointerButtonRelease(button) + private fun handleTwoFingerMove(event: MotionEvent) { + val first = event.findPointerIndex(touchPrimaryId) + val second = event.findPointerIndex(touchSecondId) + if (first < 0 || second < 0) return + val y1 = event.getY(first) + val y2 = event.getY(second) + if (!twoFingerMoved && + (Math.hypot((event.getX(first) - twoFingerStart[0]).toDouble(), (y1 - twoFingerStart[1]).toDouble()) > touchSlop || + Math.hypot((event.getX(second) - twoFingerStart[2]).toDouble(), (y2 - twoFingerStart[3]).toDouble()) > touchSlop) + ) { + twoFingerMoved = true + } + val y = (y1 + y2) * 0.5f + scrollAccumY += y - twoFingerLastY + twoFingerLastY = y + while (scrollAccumY <= -twoFingerScrollStep) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_DOWN) + scrollAccumY += twoFingerScrollStep + } + while (scrollAccumY >= twoFingerScrollStep) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_UP) + scrollAccumY -= twoFingerScrollStep } } - private fun handleTwoFingerTap(event: MotionEvent) { - if (event.pointerCount == 2 && tapToClickEnabled && !isInputSuspended) { - if (xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_LEFT)) { - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) - } - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_RIGHT) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_RIGHT) + private fun endTouchscreenPress() { + cancelPendingPress() + if (sinkTouchActive) { + sinkTouchActive = false + touchscreenSink?.onTouch(2, sinkTouchX, sinkTouchY) } + if (touchLeftPressed) { + touchLeftPressed = false + xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) + } + } + + private fun releaseTouchscreenPointers() { + endTouchscreenPress() + touchPrimaryId = -1 + touchSecondId = -1 + twoFingerGesture = false } private fun handleFingerUp(finger1: Finger) { @@ -612,32 +782,18 @@ class TouchpadView( val actionButton = event.actionButton when (event.action) { 2, 7 -> { - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) - if (xServer.isRelativeMouseMovement) { - xServer.winHandler.mouseEvent(MouseEventFlags.MOVE, transformedPoint[0].toInt(), transformedPoint[1].toInt(), 0) - updateVisibleRelativeCursor(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } else { - xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } + moveExternalMouse(event) return true } 8 -> { - val scrollY = event.getAxisValue(9) - if (scrollY <= -1.0f) { - if (xServer.isRelativeMouseMovement) xServer.winHandler.mouseEvent(MouseEventFlags.WHEEL, 0, 0, scrollY.toInt()) - else { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_DOWN) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_DOWN) - } - } else if (scrollY >= 1.0f) { - if (xServer.isRelativeMouseMovement) xServer.winHandler.mouseEvent(MouseEventFlags.WHEEL, 0, 0, scrollY.toInt()) - else { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_UP) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_UP) - } - } + onMouseWheel(event.getAxisValue(MotionEvent.AXIS_VSCROLL)) return true } + 9, 10 -> { + mouseLastX = Float.NaN + mouseLastY = Float.NaN + return false + } 11 -> { if (actionButton == 1) { if (xServer.isRelativeMouseMovement) xServer.winHandler.mouseEvent(MouseEventFlags.LEFTDOWN, 0, 0, 0) else xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT) @@ -717,9 +873,19 @@ class TouchpadView( private val pointerIdsToIgnore = mutableSetOf() + /** Pointers the on-screen controls hold; one that slides onto a control lets go of what it pressed here. */ fun setPointerIdsToIgnore(ids: Set) { pointerIdsToIgnore.clear() pointerIdsToIgnore.addAll(ids) + if (touchPrimaryId in ids || touchSecondId in ids) releaseTouchscreenPointers() + for (i in 0 until MAX_FINGERS) { + val finger = fingers[i] ?: continue + if (i !in ids) continue + releasePointerButtonLeft(finger) + releasePointerButtonRight(finger) + fingers[i] = null + numFingers = (numFingers - 1).toByte() + } } var tapToClickEnabled = true @@ -742,6 +908,15 @@ class TouchpadView( fun resetInputState() { screenTouchStick.releaseAll() rtsGestureEngine.releaseAll() + releaseTouches() + wheelAccum = 0f + mouseRemainderX = 0f + mouseRemainderY = 0f + } + + private fun releaseTouches() { + longPressHandler.removeCallbacks(longPressRunnable) + longPressActive = false continueClick = false scrolling = false scrollAccumY = 0f @@ -751,6 +926,7 @@ class TouchpadView( numFingers = 0 fingerPointerButtonLeft = null fingerPointerButtonRight = null + releaseTouchscreenPointers() if (xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_LEFT)) { xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT)