Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -839,11 +839,8 @@ fun XServerScreen(
!showElementEditor && !keepPausedForEditor && !showQuickMenu && !isEditMode &&
!container.isTouchscreenMode) {
PluviaApp.touchpadView?.postDelayed({
val view = PluviaApp.touchpadView
if (view != null) {
view.requestFocus()
view.requestPointerCapture()
}
val view = PluviaApp.touchpadView ?: return@postDelayed
view.inputCaptureManager.refreshPointerCapture()
}, 100)
Comment on lines 841 to 844
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-check capture eligibility inside the delayed refresh.

tryCapturePointer() validates showQuickMenu/editor/touchscreen state before posting, but those flags can change during the 100ms delay. A device event that races with gameBack() or edit-mode entry can still reacquire pointer capture over the overlay.

Suggested fix
-            PluviaApp.touchpadView?.postDelayed({
-                val view = PluviaApp.touchpadView ?: return@postDelayed
-                view.inputCaptureManager.refreshPointerCapture()
-            }, 100)
+            val touchpadView = PluviaApp.touchpadView
+            touchpadView?.postDelayed({
+                if (PluviaApp.touchpadView !== touchpadView || !touchpadView.isAttachedToWindow) return@postDelayed
+                if ((hasPhysicalMouse || hasInternalTouchpad) &&
+                    !showElementEditor && !keepPausedForEditor && !showQuickMenu && !isEditMode &&
+                    !container.isTouchscreenMode
+                ) {
+                    touchpadView.inputCaptureManager.refreshPointerCapture()
+                }
+            }, 100)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 841 - 844, The delayed Runnable must re-check pointer-capture eligibility
before calling refreshPointerCapture(): inside the postDelayed block (where
PluviaApp.touchpadView is captured and
view.inputCaptureManager.refreshPointerCapture() is invoked) call the same
eligibility check used by tryCapturePointer()—e.g., verify
showQuickMenu/editor/touchscreen state or invoke a boolean helper (or
tryCapturePointer() if it returns a pure eligibility result) and return early if
capture is no longer allowed (to avoid reacquiring capture after
gameBack()/edit-mode changes).

true
} else {
Expand Down Expand Up @@ -892,13 +889,11 @@ fun XServerScreen(
}

PluviaApp.touchpadView?.postDelayed({
val view = PluviaApp.touchpadView
if (view != null) {
// Delay technically not required for the function to work but this can
// race against tryCapturePointer() and end up capturing after release
// was already called
view.releasePointerCapture()
}
val view = PluviaApp.touchpadView ?: return@postDelayed
// Delay technically not required for the function to work but this can
// race against tryCapturePointer() and end up capturing after release
// was already called
view.inputCaptureManager.disablePointerCapture()
}, 100)
}
hasUpdatedScreenGamepad = false
Expand Down Expand Up @@ -961,6 +956,11 @@ fun XServerScreen(
resumeIfAllowedAfterOverlay()
}
}
PluviaApp.touchpadView?.postDelayed({
val view = PluviaApp.touchpadView ?: return@postDelayed
view.requestFocus()
view.inputCaptureManager.refreshPointerCapture()
}, 100)
showQuickMenu = false
}

Expand Down Expand Up @@ -1265,13 +1265,11 @@ fun XServerScreen(
controllerManager.scanForDevices()
hasPhysicalController = controllerManager.getDetectedDevices().isNotEmpty()
PluviaApp.touchpadView?.postDelayed({
val view = PluviaApp.touchpadView
if (view != null) {
// Delay technically not required for the function to work but this can
// race against tryCapturePointer() and end up capturing after release
// was already called
view.releasePointerCapture()
}
val view = PluviaApp.touchpadView ?: return@postDelayed
// Delay technically not required for the function to work but this can
// race against tryCapturePointer() and end up capturing after release
// was already called
view.inputCaptureManager?.disablePointerCapture()
}, 100)

showQuickMenu = true
Expand Down Expand Up @@ -1370,19 +1368,35 @@ fun XServerScreen(
if (!handled) handled = xServerView!!.getxServer().winHandler.onKeyEvent(it.event)
}
if (!handled && isKeyboard) {
val isShiftEscPressed = it.event.keyCode == KeyEvent.KEYCODE_ESCAPE &&
it.event.isShiftPressed &&
it.event.action == KeyEvent.ACTION_DOWN &&
it.event.repeatCount == 0
if (isShiftEscPressed &&
!showElementEditor && !keepPausedForEditor && !showQuickMenu && !isEditMode) {
gameBack()
handled = true
} else {
if (it.event.device?.isVirtual == true) {
handled = keyboard?.onVirtualKeyEvent(it.event) == true
val areAllSpecialKeysUp = it.event.isCtrlPressed and it.event.isShiftPressed and it.event.isAltPressed && it.event.action == KeyEvent.ACTION_UP
Comment thread
Unnvaldr marked this conversation as resolved.
if (areAllSpecialKeysUp) {
// Handing special key combination
when (it.event.keyCode) {
KeyEvent.KEYCODE_Z -> {
// Toggles pointer exclusivity when in game view
PluviaApp.touchpadView?.inputCaptureManager?.togglePointerCapture()
handled = true
Comment thread
Unnvaldr marked this conversation as resolved.
}
else -> {
handled = false
}
}
}
if (!handled) {
val isShiftEscPressed = it.event.keyCode == KeyEvent.KEYCODE_ESCAPE &&
it.event.isShiftPressed &&
it.event.action == KeyEvent.ACTION_DOWN &&
it.event.repeatCount == 0
if (isShiftEscPressed &&
!showElementEditor && !keepPausedForEditor && !showQuickMenu && !isEditMode) {
gameBack()
handled = true
} else {
handled = keyboard?.onKeyEvent(it.event) == true
if (it.event.device?.isVirtual == true) {
handled = keyboard?.onVirtualKeyEvent(it.event) == true
} else {
handled = keyboard?.onKeyEvent(it.event) == true
Comment thread
Unnvaldr marked this conversation as resolved.
}
}
}
}
Expand Down Expand Up @@ -1418,7 +1432,6 @@ fun XServerScreen(
areControlsVisible = false
}
}
tryCapturePointer()
}
}
handled
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.winlator.inputcontrols;

import android.view.View;

import timber.log.Timber;

public class InputCaptureManager {
private final View targetView;

private boolean captureEnabled = false;
private boolean pointerCaptureRequested = true;

public InputCaptureManager(View targetView) {
this.targetView = targetView;
}

public boolean isCaptureEnabled() {
return captureEnabled;
}

public void setCaptureEnabled(boolean captureEnabled) {
this.captureEnabled = captureEnabled;
}

public boolean isPointerCaptureRequested() {
return pointerCaptureRequested;
}

public void setPointerCaptureRequested(boolean pointerCaptureRequested) {
this.pointerCaptureRequested = pointerCaptureRequested;
}

public void onWindowFocusChanged(@SuppressWarnings("unused") boolean hasFocus) {
refreshPointerCapture();
}

public void onAttachedToWindow() {
enablePointerCapture();
}

public void onDetachedFromWindow() {
disablePointerCapture();
}

public void refreshPointerCapture() {
boolean shouldCapture = targetView.hasFocus() && pointerCaptureRequested;
boolean hasCapture = targetView.hasPointerCapture();

if (shouldCapture && !hasCapture) {
enablePointerCapture();
setPointerCaptureRequested(true);
} else if (!shouldCapture && hasCapture) {
disablePointerCapture();
setPointerCaptureRequested(false);
}
}

public void togglePointerCapture() {
if (!captureEnabled) return;
if (targetView.hasPointerCapture()) {
disablePointerCapture();
setPointerCaptureRequested(false);
} else {
enablePointerCapture();
setPointerCaptureRequested(true);
}
}

public void disablePointerCapture() {
if (!captureEnabled) return;
if (!targetView.hasPointerCapture() || !pointerCaptureRequested) {
Timber.tag("TouchpadView").v("Pointer capture: Pointer capture not detected, skipped");
return;
}
targetView.releasePointerCapture();
Timber.tag("TouchpadView").v("Pointer capture: Pointer capture release (state=%s).", targetView.hasPointerCapture());
}

public void enablePointerCapture() {
if (!captureEnabled) return;
if (!targetView.hasFocus() && !targetView.requestFocus()) {
Timber.tag("TouchpadView").w("Pointer capture: Unable to request pointer capture, view is unfocused and cannot regain focus!");
return;
}
if (targetView.hasPointerCapture() && !pointerCaptureRequested) {
Comment thread
Unnvaldr marked this conversation as resolved.
Timber.v("Pointer capture: Pointer capture already requested, skipped");
return;
}
targetView.requestPointerCapture();
Timber.tag("TouchpadView").v("Pointer capture: Pointer capture request (state=%s).", targetView.hasPointerCapture());
}
}
42 changes: 27 additions & 15 deletions app/src/main/java/com/winlator/widget/TouchpadView.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import app.gamenative.R;
import app.gamenative.data.TouchGestureConfig;
import com.winlator.core.AppUtils;
import com.winlator.inputcontrols.InputCaptureManager;
import com.winlator.math.Mathf;
import com.winlator.math.XForm;
import com.winlator.renderer.ViewTransformation;
Expand Down Expand Up @@ -62,8 +63,8 @@ public class TouchpadView extends View implements View.OnCapturedPointerListener
private Runnable delayedPress;

private boolean pressExecuted;
private final boolean capturePointerOnExternalMouse;
private boolean pointerCaptureRequested;

private final InputCaptureManager inputCaptureManager;

// Suppress spurious left-click after two-finger right-click tap
private boolean suppressNextLeftTap;
Expand Down Expand Up @@ -197,23 +198,12 @@ private void stopGestureRefresh() {
}
}

@Override
protected void onDetachedFromWindow() {
// Full cleanup on detach: cancels timers, refresh runnable, releases
// any held drag/long-press/2F/3F-hold injections, and resets gesture
// state. Avoids leaking pressed buttons/keys when the view is removed
// mid-gesture (e.g., game exit while a hold is active).
handleTsCancel();
super.onDetachedFromWindow();
}

// Left/right click drag tracking
private boolean leftClickDragButtonDown;
private boolean rightClickDragButtonDown;

public TouchpadView(Context context, XServer xServer, boolean capturePointerOnExternalMouse) {
super(context);
this.capturePointerOnExternalMouse = capturePointerOnExternalMouse;
this.fingers = new Finger[4];
this.numFingers = (byte) 0;
this.sensitivity = 1.0f;
Expand All @@ -237,17 +227,39 @@ public TouchpadView(Context context, XServer xServer, boolean capturePointerOnEx
int screenHeight = AppUtils.getScreenHeight();
ScreenInfo screenInfo = xServer.screenInfo;
updateXform(screenWidth, screenHeight, screenInfo.width, screenInfo.height);
this.inputCaptureManager = new InputCaptureManager(this);
if (capturePointerOnExternalMouse) {
inputCaptureManager.setCaptureEnabled(true);
setFocusableInTouchMode(true);
setOnCapturedPointerListener(this);
}
}

public InputCaptureManager getInputCaptureManager() {
return inputCaptureManager;
}

@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
// allow re-capture after app returns from background
if (hasFocus) pointerCaptureRequested = false;
this.inputCaptureManager.onWindowFocusChanged(hasFocus);
}

@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
this.inputCaptureManager.onAttachedToWindow();
}

@Override
protected void onDetachedFromWindow() {
// Full cleanup on detach: cancels timers, refresh runnable, releases
// any held drag/long-press/2F/3F-hold injections, and resets gesture
// state. Avoids leaking pressed buttons/keys when the view is removed
// mid-gesture (e.g., game exit while a hold is active).
handleTsCancel();
super.onDetachedFromWindow();
this.inputCaptureManager.onDetachedFromWindow();
}

private static StateListDrawable createTransparentBackground() {
Expand Down
Loading