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..c43c93a0 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,12 @@ 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]; + /** 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; @@ -270,6 +279,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 +307,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 +335,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 +884,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()) 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 +949,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()) return; + menuManager.showMenu(findViewById(R.id.decoder), + quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts); + }); return hint; } @@ -2130,6 +2150,66 @@ 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: + 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; + case MotionEvent.ACTION_CANCEL: + tapGuard.cancel(); + break; + default: + break; + } + 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) or + * accessibility always, from a touch only when it was a real tap (#213). + */ + private boolean touchTapOrKey() { + return !clickFromTouch || 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..2e7416db --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/TapGuard.java @@ -0,0 +1,89 @@ +/* + * 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. 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). */ + 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 boolean lastTap; + 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 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; + } + + void cancel() { + tap = false; + lastTap = false; + } + + /** Whether the last finished sequence was a tap that may open the menu. */ + boolean wasTap() { + 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 new file mode 100644 index 00000000..d7703af1 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/TapGuardTest.java @@ -0,0 +1,110 @@ +/* + * 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.up(805, 403); + assertTrue(g.wasTap()); + } + + @Test + public void swipeFromTheTopEdgeIsNoTap() { + TapGuard g = new TapGuard(14); + g.down(800, 0, W, H, ZONE); + g.up(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.up(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.up(800, 400); + assertFalse(g.wasTap()); + } + + @Test + 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(); + assertFalse(g.wasTap()); + } + + @Test + 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); + 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 + public void nothingTouchedIsNoTap() { + assertFalse(new TapGuard(14).wasTap()); + } +}