From eb860da342a2099476dccf91a442402977a166fd Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Thu, 15 Oct 2026 23:01:12 +0000 Subject: [PATCH 1/2] fix: a swipe from the screen edge no longer opens the menu (#213) GestureDetector confirms a single tap from a down and an up with no move between, wherever the up is: when the system keeps the moves of its edge swipe (status bar), the app saw a tap and opened the menu. TapGuard follows every touch sequence in Activity.dispatchTouchEvent; the menu opens on a touch only when the sequence started outside the edge zone (the larger of the system gesture insets, Android 10+, and a fixed band of 24 dp top/bottom and 16 dp left/right), the finger stayed within the touch slop (up included), with one finger and no cancel. Applies to the single view, the grid cells and the U hint; keys (remote, D-pad) are unchanged. Version 2.13 (38). Fixes #213 --- android/app/build.gradle | 4 +- .../java/com/openipc/decoder/Decoder.java | 92 ++++++++++++++-- .../java/com/openipc/decoder/TapGuard.java | 78 ++++++++++++++ .../com/openipc/decoder/TapGuardTest.java | 102 ++++++++++++++++++ 4 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/TapGuard.java create mode 100644 android/app/src/test/java/com/openipc/decoder/TapGuardTest.java diff --git a/android/app/build.gradle b/android/app/build.gradle index 7419bbaa..8fadf408 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 37 - versionName "2.12" + versionCode 38 + versionName "2.13" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" } diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 38eb6066..3d550ec9 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -8,6 +8,7 @@ package com.openipc.decoder; import android.annotation.SuppressLint; +import android.annotation.TargetApi; import android.app.Activity; import android.content.ActivityNotFoundException; @@ -18,6 +19,7 @@ import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.Color; +import android.graphics.Insets; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -38,6 +40,7 @@ import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.View; +import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.WindowInsets; import android.view.WindowInsetsController; @@ -153,6 +156,10 @@ public class Decoder extends Activity // Pinch-to-zoom private ScaleGestureDetector mScaleDetector; private GestureDetector mGestureDetector; + // A swipe from the screen edge or a drag is no tap that opens the menu (#213) + private TapGuard tapGuard; + /** The system's gesture insets (left, top, right, bottom), Android 10+. */ + private final int[] gestureInsets = new int[4]; private float mZoomScale = 1.0f; private float mPanX; private float mPanY; @@ -270,6 +277,7 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { } }); + tapGuard = new TapGuard(ViewConfiguration.get(this).getScaledTouchSlop()); // Pinch-to-zoom mScaleDetector = new ScaleGestureDetector(this, new ScaleGestureDetector.SimpleOnScaleGestureListener() { @Override public boolean onScale(ScaleGestureDetector d) { @@ -297,6 +305,7 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { } @Override public boolean onDoubleTap(MotionEvent e) { resetZoom(); return true; } @Override public boolean onSingleTapConfirmed(MotionEvent e) { + if (!tapGuard.wasTap()) return true; menuManager.showMenu(menuAnchor, quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts); return true; @@ -324,14 +333,18 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Bars that stay shown while the video has the focus (not the transient ones after - // a swipe, which the app does not see) go away again after a moment (#202) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { View decor = getWindow().getDecorView(); decor.setOnApplyWindowInsetsListener((v, insets) -> { - v.removeCallbacks(hideBarsRunnable); - if (hasWindowFocus() && insets.isVisible(WindowInsets.Type.systemBars())) { - v.postDelayed(hideBarsRunnable, BARS_REHIDE_MS); + saveGestureInsets(insets); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Bars that stay shown while the video has the focus (not the transient + // ones after a swipe, which the app does not see) go away again after a + // moment (#202) + v.removeCallbacks(hideBarsRunnable); + if (hasWindowFocus() && insets.isVisible(WindowInsets.Type.systemBars())) { + v.postDelayed(hideBarsRunnable, BARS_REHIDE_MS); + } } return v.onApplyWindowInsets(insets); }); @@ -869,9 +882,11 @@ private void buildQuadGrid(int size) { for (int col = 0; col < size; col++) { int idx = size == 1 ? U_CELL : row * size + col; quadViews[idx] = new SurfaceView(this); - quadViews[idx].setOnClickListener(cv -> - menuManager.showMenu(findViewById(R.id.decoder), quadEnabled, mActive, mVersion, - BuildConfig.GIT_HASH, mHosts)); + quadViews[idx].setOnClickListener(cv -> { + if (!touchTapOrKey(cv)) return; + menuManager.showMenu(findViewById(R.id.decoder), quadEnabled, mActive, mVersion, + BuildConfig.GIT_HASH, mHosts); + }); // Each cell is a FrameLayout so overlays can be stacked on the video FrameLayout cell = new FrameLayout(this); cell.addView(quadViews[idx], new FrameLayout.LayoutParams( @@ -932,8 +947,11 @@ private TextView udpHintCell() { hint.setPadding(dp(12), dp(12), dp(12), dp(12)); hint.setBackgroundColor(Color.BLACK); // Like the camera cells and the video view: a tap opens the menu - hint.setOnClickListener(v -> menuManager.showMenu(findViewById(R.id.decoder), - quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts)); + hint.setOnClickListener(v -> { + if (!touchTapOrKey(v)) return; + menuManager.showMenu(findViewById(R.id.decoder), + quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts); + }); return hint; } @@ -2130,6 +2148,58 @@ private void replaceSurface(Surface next) { videoDecoder.setSurface(next); } + /** The system's gesture insets, where a swipe is the system's and no tap (#213). */ + @TargetApi(Build.VERSION_CODES.Q) + @SuppressWarnings("deprecation") + private void saveGestureInsets(WindowInsets insets) { + Insets g = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + ? insets.getInsets(WindowInsets.Type.systemGestures()) + : insets.getSystemGestureInsets(); + gestureInsets[0] = g.left; + gestureInsets[1] = g.top; + gestureInsets[2] = g.right; + gestureInsets[3] = g.bottom; + } + + /** + * Every touch of the window passes here before the views get it: TapGuard follows the + * sequence, so the menu opens on a real tap only, not on a swipe from the edge that shows + * the bars (#213) or a drag. Keys (remote, D-pad) do not come here. + */ + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (tapGuard == null) return super.dispatchTouchEvent(ev); + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + View decor = getWindow().getDecorView(); + tapGuard.down(ev.getX(), ev.getY(), decor.getWidth(), decor.getHeight(), + TapGuard.edgeZone(gestureInsets, mDensity)); + break; + } + case MotionEvent.ACTION_MOVE: + case MotionEvent.ACTION_UP: + tapGuard.moveTo(ev.getX(), ev.getY()); + break; + case MotionEvent.ACTION_POINTER_DOWN: + tapGuard.secondFinger(); + break; + case MotionEvent.ACTION_CANCEL: + tapGuard.cancel(); + break; + default: + break; + } + return super.dispatchTouchEvent(ev); + } + + /** + * A click on a cell or the U hint opens the menu: from a key (remote, D-pad) always, from + * a touch only when it was a real tap (#213). + */ + private boolean touchTapOrKey(View v) { + return !v.isInTouchMode() || tapGuard.wasTap(); + } + private int dp(float dp) { return (int) (dp * mDensity + 0.5f); } diff --git a/android/app/src/main/java/com/openipc/decoder/TapGuard.java b/android/app/src/main/java/com/openipc/decoder/TapGuard.java new file mode 100644 index 00000000..4ca5070a --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/TapGuard.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * TapGuard.java — tells a real tap on the picture from a swipe at the screen edge + * + */ + +package com.openipc.decoder; + +/** + * Follows one touch sequence on the screen (#213) and says whether it was a tap that may + * open the menu. Not a tap: a sequence that starts in the edge zone, where the system takes + * swipes to show its bars or go back; one whose finger moved more than the touch slop, also + * when the system kept the moves and only the up arrived (GestureDetector takes a down and a + * far-away up with no move between as a tap); one with a second finger (pinch); one that was + * cancelled. Coordinates are in pixels of the screen; no Android classes, for the unit tests. + */ +class TapGuard { + /** Edge zone where the system has no gesture insets (before Android 10, or 0 reported). */ + static final int EDGE_TOP_DP = 24; + static final int EDGE_BOTTOM_DP = 24; + static final int EDGE_SIDE_DP = 16; + + private final float slop; + private boolean tap; + private float downX, downY; + + TapGuard(float touchSlopPx) { + slop = touchSlopPx; + } + + /** + * Whether (x, y) is in the edge zone of a w x h screen: within the given inset of + * each side (left, top, right, bottom). + */ + static boolean startsInEdgeZone(float x, float y, int w, int h, int[] insets) { + return x < insets[0] || y < insets[1] || x >= w - insets[2] || y >= h - insets[3]; + } + + /** Per side the larger of the system's gesture inset and the fixed edge band. */ + static int[] edgeZone(int[] gestureInsets, float density) { + int[] fixed = { + Math.round(EDGE_SIDE_DP * density), Math.round(EDGE_TOP_DP * density), + Math.round(EDGE_SIDE_DP * density), Math.round(EDGE_BOTTOM_DP * density)}; + int[] zone = new int[4]; + for (int i = 0; i < 4; i++) { + zone[i] = Math.max(fixed[i], gestureInsets == null ? 0 : gestureInsets[i]); + } + return zone; + } + + /** First finger down: a new sequence. */ + void down(float x, float y, int w, int h, int[] zone) { + downX = x; + downY = y; + tap = !startsInEdgeZone(x, y, w, h, zone); + } + + /** A move or the up of the first finger. */ + void moveTo(float x, float y) { + float dx = x - downX, dy = y - downY; + if (dx * dx + dy * dy > slop * slop) tap = false; + } + + /** A second finger: a pinch, not a tap. */ + void secondFinger() { + tap = false; + } + + void cancel() { + tap = false; + } + + /** Whether the last sequence was a tap that may open the menu. */ + boolean wasTap() { + return tap; + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java b/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java new file mode 100644 index 00000000..19ee9889 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * TapGuardTest.java — a swipe from the screen edge or a drag is no tap (#213) + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class TapGuardTest { + private static final int W = 1600, H = 720; + private static final int[] ZONE = {28, 47, 28, 47}; + + @Test + public void edgeZoneOnEachSide() { + assertTrue(TapGuard.startsInEdgeZone(800, 0, W, H, ZONE)); + assertTrue(TapGuard.startsInEdgeZone(800, 46, W, H, ZONE)); + assertFalse(TapGuard.startsInEdgeZone(800, 47, W, H, ZONE)); + assertTrue(TapGuard.startsInEdgeZone(800, 719, W, H, ZONE)); + assertTrue(TapGuard.startsInEdgeZone(800, H - 47, W, H, ZONE)); + assertFalse(TapGuard.startsInEdgeZone(800, H - 48, W, H, ZONE)); + assertTrue(TapGuard.startsInEdgeZone(0, 360, W, H, ZONE)); + assertTrue(TapGuard.startsInEdgeZone(1599, 360, W, H, ZONE)); + assertFalse(TapGuard.startsInEdgeZone(800, 360, W, H, ZONE)); + } + + @Test + public void zoneIsTheLargerOfGestureInsetsAndFixedBand() { + // 280 dpi: density 1.75; 16 dp = 28 px, 24 dp = 42 px + assertArrayEquals(new int[] {28, 42, 28, 42}, TapGuard.edgeZone(null, 1.75f)); + assertArrayEquals(new int[] {28, 42, 28, 42}, TapGuard.edgeZone(new int[4], 1.75f)); + // Gesture navigation: wide back zones left and right, a taller one at the bottom + assertArrayEquals(new int[] {53, 42, 53, 84}, + TapGuard.edgeZone(new int[] {53, 0, 53, 84}, 1.75f)); + } + + @Test + public void tapInTheMiddleOpensTheMenu() { + TapGuard g = new TapGuard(14); + g.down(800, 400, W, H, ZONE); + g.moveTo(805, 403); + g.moveTo(805, 403); + assertTrue(g.wasTap()); + } + + @Test + public void swipeFromTheTopEdgeIsNoTap() { + TapGuard g = new TapGuard(14); + g.down(800, 0, W, H, ZONE); + g.moveTo(800, 2); + assertFalse(g.wasTap()); + } + + @Test + public void upFarAwayWithoutMovesIsNoTap() { + // GestureDetector takes this as a tap: the system kept the moves of its swipe + TapGuard g = new TapGuard(14); + g.down(800, 100, W, H, ZONE); + g.moveTo(800, 400); + assertFalse(g.wasTap()); + } + + @Test + public void dragBackWithinSlopIsStillNoTap() { + TapGuard g = new TapGuard(14); + g.down(800, 400, W, H, ZONE); + g.moveTo(900, 400); + g.moveTo(800, 400); + assertFalse(g.wasTap()); + } + + @Test + public void pinchAndCancelAreNoTap() { + TapGuard g = new TapGuard(14); + g.down(800, 400, W, H, ZONE); + g.secondFinger(); + assertFalse(g.wasTap()); + g.down(800, 400, W, H, ZONE); + g.cancel(); + assertFalse(g.wasTap()); + } + + @Test + public void newSequenceStartsAfresh() { + TapGuard g = new TapGuard(14); + g.down(800, 0, W, H, ZONE); + assertFalse(g.wasTap()); + g.down(800, 400, W, H, ZONE); + assertTrue(g.wasTap()); + } + + @Test + public void nothingTouchedIsNoTap() { + assertFalse(new TapGuard(14).wasTap()); + } +} From 8d51e2e33705950b82af8c62cd358d7761db1978 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Thu, 15 Oct 2026 23:05:32 +0000 Subject: [PATCH 2/2] fix: the tap answer is the last finished sequence's; keys and accessibility click (Qodo on #216) - TapGuard answers for the last finished sequence (up or cancel), so a tap confirmed late by GestureDetector, or a click posted after the up, is not judged by a new touch that has just begun. - Cells and the U hint gate only clicks that come from a touch's up (clickFromTouch, cleared after the posted click); key, accessibility and programmatic clicks open the menu as before, also in touch mode. --- .../java/com/openipc/decoder/Decoder.java | 26 +++++++++++++------ .../java/com/openipc/decoder/TapGuard.java | 19 +++++++++++--- .../com/openipc/decoder/TapGuardTest.java | 20 +++++++++----- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 3d550ec9..c43c93a0 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -160,6 +160,8 @@ public class Decoder extends Activity private TapGuard tapGuard; /** The system's gesture insets (left, top, right, bottom), Android 10+. */ private final int[] gestureInsets = new int[4]; + /** Set from a touch's up until the click it posts has run (#213). */ + private boolean clickFromTouch; private float mZoomScale = 1.0f; private float mPanX; private float mPanY; @@ -883,7 +885,7 @@ private void buildQuadGrid(int size) { int idx = size == 1 ? U_CELL : row * size + col; quadViews[idx] = new SurfaceView(this); quadViews[idx].setOnClickListener(cv -> { - if (!touchTapOrKey(cv)) return; + if (!touchTapOrKey()) return; menuManager.showMenu(findViewById(R.id.decoder), quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts); }); @@ -948,7 +950,7 @@ private TextView udpHintCell() { hint.setBackgroundColor(Color.BLACK); // Like the camera cells and the video view: a tap opens the menu hint.setOnClickListener(v -> { - if (!touchTapOrKey(v)) return; + if (!touchTapOrKey()) return; menuManager.showMenu(findViewById(R.id.decoder), quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts); }); @@ -2177,9 +2179,12 @@ public boolean dispatchTouchEvent(MotionEvent ev) { break; } case MotionEvent.ACTION_MOVE: - case MotionEvent.ACTION_UP: tapGuard.moveTo(ev.getX(), ev.getY()); break; + case MotionEvent.ACTION_UP: + tapGuard.up(ev.getX(), ev.getY()); + clickFromTouch = true; + break; case MotionEvent.ACTION_POINTER_DOWN: tapGuard.secondFinger(); break; @@ -2189,15 +2194,20 @@ public boolean dispatchTouchEvent(MotionEvent ev) { default: break; } - return super.dispatchTouchEvent(ev); + boolean handled = super.dispatchTouchEvent(ev); + if (ev.getActionMasked() == MotionEvent.ACTION_UP) { + // A view clicks after the up, posted: this runs after that click + getWindow().getDecorView().post(() -> clickFromTouch = false); + } + return handled; } /** - * A click on a cell or the U hint opens the menu: from a key (remote, D-pad) always, from - * a touch only when it was a real tap (#213). + * A click on a cell or the U hint opens the menu: from a key (remote, D-pad) or + * accessibility always, from a touch only when it was a real tap (#213). */ - private boolean touchTapOrKey(View v) { - return !v.isInTouchMode() || tapGuard.wasTap(); + private boolean touchTapOrKey() { + return !clickFromTouch || tapGuard.wasTap(); } private int dp(float dp) { diff --git a/android/app/src/main/java/com/openipc/decoder/TapGuard.java b/android/app/src/main/java/com/openipc/decoder/TapGuard.java index 4ca5070a..2e7416db 100644 --- a/android/app/src/main/java/com/openipc/decoder/TapGuard.java +++ b/android/app/src/main/java/com/openipc/decoder/TapGuard.java @@ -13,7 +13,10 @@ * swipes to show its bars or go back; one whose finger moved more than the touch slop, also * when the system kept the moves and only the up arrived (GestureDetector takes a down and a * far-away up with no move between as a tap); one with a second finger (pinch); one that was - * cancelled. Coordinates are in pixels of the screen; no Android classes, for the unit tests. + * cancelled. The answer is the one of the last finished sequence (up or cancel), so a tap + * confirmed after its up (GestureDetector waits for a double tap) or a click posted after it + * gets its own sequence's answer, not one of a new touch that has just begun. Coordinates are + * in pixels of the screen; no Android classes, for the unit tests. */ class TapGuard { /** Edge zone where the system has no gesture insets (before Android 10, or 0 reported). */ @@ -23,6 +26,7 @@ class TapGuard { private final float slop; private boolean tap; + private boolean lastTap; private float downX, downY; TapGuard(float touchSlopPx) { @@ -56,12 +60,18 @@ void down(float x, float y, int w, int h, int[] zone) { tap = !startsInEdgeZone(x, y, w, h, zone); } - /** A move or the up of the first finger. */ + /** A move of the first finger. */ void moveTo(float x, float y) { float dx = x - downX, dy = y - downY; if (dx * dx + dy * dy > slop * slop) tap = false; } + /** The up of the last finger: the sequence is finished. */ + void up(float x, float y) { + moveTo(x, y); + lastTap = tap; + } + /** A second finger: a pinch, not a tap. */ void secondFinger() { tap = false; @@ -69,10 +79,11 @@ void secondFinger() { void cancel() { tap = false; + lastTap = false; } - /** Whether the last sequence was a tap that may open the menu. */ + /** Whether the last finished sequence was a tap that may open the menu. */ boolean wasTap() { - return tap; + return lastTap; } } diff --git a/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java b/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java index 19ee9889..d7703af1 100644 --- a/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java +++ b/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java @@ -45,7 +45,7 @@ public void tapInTheMiddleOpensTheMenu() { TapGuard g = new TapGuard(14); g.down(800, 400, W, H, ZONE); g.moveTo(805, 403); - g.moveTo(805, 403); + g.up(805, 403); assertTrue(g.wasTap()); } @@ -53,7 +53,7 @@ public void tapInTheMiddleOpensTheMenu() { public void swipeFromTheTopEdgeIsNoTap() { TapGuard g = new TapGuard(14); g.down(800, 0, W, H, ZONE); - g.moveTo(800, 2); + g.up(800, 2); assertFalse(g.wasTap()); } @@ -62,7 +62,7 @@ public void upFarAwayWithoutMovesIsNoTap() { // GestureDetector takes this as a tap: the system kept the moves of its swipe TapGuard g = new TapGuard(14); g.down(800, 100, W, H, ZONE); - g.moveTo(800, 400); + g.up(800, 400); assertFalse(g.wasTap()); } @@ -71,7 +71,7 @@ public void dragBackWithinSlopIsStillNoTap() { TapGuard g = new TapGuard(14); g.down(800, 400, W, H, ZONE); g.moveTo(900, 400); - g.moveTo(800, 400); + g.up(800, 400); assertFalse(g.wasTap()); } @@ -80,6 +80,7 @@ public void pinchAndCancelAreNoTap() { TapGuard g = new TapGuard(14); g.down(800, 400, W, H, ZONE); g.secondFinger(); + g.up(800, 400); assertFalse(g.wasTap()); g.down(800, 400, W, H, ZONE); g.cancel(); @@ -87,12 +88,19 @@ public void pinchAndCancelAreNoTap() { } @Test - public void newSequenceStartsAfresh() { + public void answerIsTheLastFinishedSequence() { + // Qodo on #216: a new touch begun before a late tap confirmation does not change it TapGuard g = new TapGuard(14); g.down(800, 0, W, H, ZONE); - assertFalse(g.wasTap()); + g.up(800, 300); g.down(800, 400, W, H, ZONE); + assertFalse(g.wasTap()); + g.up(800, 400); assertTrue(g.wasTap()); + g.down(800, 0, W, H, ZONE); + assertTrue(g.wasTap()); + g.up(800, 300); + assertFalse(g.wasTap()); } @Test