From 061a4befdc3fea2562ad94cbe8db0ff693872b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Miret?= Date: Fri, 24 Oct 2025 16:32:25 +0200 Subject: [PATCH 1/6] Add preference to disable double-tap detection --- app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values/arrays.xml | 4 +++- app/src/main/res/values/strings.xml | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 630e81f1..641b7089 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -240,4 +240,5 @@ Style de geste \'Touchpad\' est actif Désactivé par la valeur actuelle du geste Pression longue Faites glisser depuis le bord pour ouvrir + Deux clics gauches diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 98eed7fd..5ed692e7 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -75,12 +75,14 @@ @string/pref_gesture_action_double_click @string/pref_gesture_action_middle_click @string/pref_gesture_action_right_click + @string/pref_gesture_action_two_left_clicks none double-click middle-click right-click + two-left-clicks @@ -227,4 +229,4 @@ Item 2 Item 3 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b222d57e..81ec2029 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -176,6 +176,7 @@ Drag with middle button Back-press on server Open keyboard + Two left-clicks Theme System From 41dc29a5abcb7be582e5aad937cde4dd3cb4afe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Miret?= Date: Fri, 24 Oct 2025 16:38:47 +0200 Subject: [PATCH 2/6] Test single tap is immediately sent --- .../avnc/ui/vnc/input/TouchHandlerTest.kt | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt b/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt index 233180ff..af5942c3 100644 --- a/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt +++ b/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt @@ -60,10 +60,15 @@ class TouchHandlerTest { } - private fun setupWithPref(mousePassthrough: Boolean = false, dragEnabled: Boolean = false) { + private fun setupWithPref( + mousePassthrough: Boolean = false, + dragEnabled: Boolean = false, + doubleClickEnabled: Boolean = true, + ) { targetPrefs.edit { putBoolean("mouse_passthrough", mousePassthrough) putString("gesture_drag", if (dragEnabled) "remote-scroll" else "none") + putString("gesture_double_tap", if (doubleClickEnabled) "double-click" else "two-left-clicks") } setup() } @@ -78,6 +83,16 @@ class TouchHandlerTest { verify { mockDispatcher.onTap1(testPoint) } } + @Test + fun singleTapImmediatelySent() { + setupWithPref(doubleClickEnabled = false) + + sendDown() + sendUp() + + verify { mockDispatcher.onTap1(testPoint) } + } + @Test fun doubleTap() { sendDown() @@ -88,6 +103,19 @@ class TouchHandlerTest { verify { mockDispatcher.onDoubleTap(testPoint) } } + @Test + fun doubleTapAsTwoClicks() { + setupWithPref(doubleClickEnabled = false) + + sendDown() + sendUp() + Thread.sleep(Delay.BETWEEN_DOUBLE_TAPS) + sendDown() + sendUp() + + verify(exactly = 2) { mockDispatcher.onTap1(testPoint) } + } + @Test fun twoFingerTap() { sendDown() @@ -519,4 +547,4 @@ class TouchHandlerTest { private fun sendStylusUp(p: PointF = testPoint) = sendEvent(Factory.obtainUpEvent(downEvent, p)) private fun sendStylusMove(p: PointF) = sendEvent(Factory.obtainMoveEvent(downEvent, p)) -} \ No newline at end of file +} From 8c3ce3c116c7f88ce0324cd797430ad67dab7309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Miret?= Date: Fri, 24 Oct 2025 17:02:04 +0200 Subject: [PATCH 3/6] Send single tap immediately when double tap detection is off --- .../gaurav/avnc/ui/vnc/input/TouchHandler.kt | 81 +++++++++++++++++-- .../com/gaurav/avnc/util/AppPreferences.kt | 3 +- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt b/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt index 90e295f7..cbb29050 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt @@ -218,7 +218,12 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis * Finger Gestures (and everything else beside mouse & stylus) ****************************************************************************************/ private val scaleDetector = ScaleGestureDetector(frameView.context, this).apply { isQuickScaleEnabled = false } - private val gestureDetector = GestureDetectorEx(frameView.context, FingerGestureListener(), pref.input.gesture.longPressDetectionEnabled) + private val gestureDetector = GestureDetectorEx( + frameView.context, + FingerGestureListener(), + pref.input.gesture.longPressDetectionEnabled, + pref.input.gesture.doubleClickDetectionEnabled, + ) private val swipeVsScale = SwipeVsScale() private val longPressSwipeEnabled = pref.input.gesture.longPressSwipeEnabled private val swipeSensitivity = pref.input.gesture.swipeSensitivity @@ -297,7 +302,12 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis * [GestureDetectorEx] is used to for this purpose. It internally uses stock * [GestureDetector], and some custom event processing to detect more gestures. */ - private class GestureDetectorEx(context: Context, val listener: GestureListenerEx, val enableLongPress: Boolean) { + private class GestureDetectorEx( + context: Context, + val listener: GestureListenerEx, + val enableLongPress: Boolean, + enableDoubleClick: Boolean, + ) { /** * Detected gestures. Some of these come directly from stock [GestureDetector], @@ -365,9 +375,22 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis * - (double-tap) (double-tap-swipe) * */ - private val innerDetector1 = GestureDetector(context, InnerListener1()) - private val innerDetector2 = GestureDetector(context, InnerListener2()).apply { setIsLongpressEnabled(false) } - private val innerDetector3 = GestureDetector(context, InnerListener3()).apply { setIsLongpressEnabled(false) } + private val innerListener1: InnerListener1 = when (enableDoubleClick) { + true -> DoubleTapListener1() + false -> RawListener1() + } + private val innerListener2: InnerListener2 = when (enableDoubleClick) { + true -> DoubleTapListener2() + false -> RawListener2() + } + private val innerListener3: InnerListener3 = when (enableDoubleClick) { + true -> DoubleTapListener3() + false -> RawListener3() + } + + private val innerDetector1 = GestureDetector(context, innerListener1) + private val innerDetector2 = GestureDetector(context, innerListener2).apply { setIsLongpressEnabled(false) } + private val innerDetector3 = GestureDetector(context, innerListener3).apply { setIsLongpressEnabled(false) } private var longPressDetected = false private var doubleTapDetected = false @@ -378,8 +401,18 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis private var cumulatedY = 0f private val multiTapSlopSquare = 30 * 30 + private open class RawListener : GestureDetector.OnGestureListener { + override fun onDown(e: MotionEvent): Boolean = false + override fun onShowPress(e: MotionEvent) {} + override fun onSingleTapUp(e: MotionEvent): Boolean = false + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float): Boolean = false + override fun onLongPress(e: MotionEvent) {} + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = false + } + + private interface InnerListener1 : GestureDetector.OnGestureListener - private inner class InnerListener1 : SimpleOnGestureListener() { + private inner class DoubleTapListener1 : SimpleOnGestureListener(), InnerListener1 { override fun onSingleTapConfirmed(e: MotionEvent): Boolean { listener.onSingleTapConfirmed(e) return true @@ -402,7 +435,29 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis } } - private inner class InnerListener2 : SimpleOnGestureListener() { + private inner class RawListener1 : RawListener(), InnerListener1 { + override fun onSingleTapUp(e: MotionEvent): Boolean { + listener.onSingleTapConfirmed(e) + return true + } + + override fun onLongPress(e: MotionEvent) { + if (!enableLongPress) + return + + longPressDetected = true + listener.onLongPress(e) + } + + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + listener.onFling(velocityX, velocityY) + return true + } + } + + private interface InnerListener2 : GestureDetector.OnGestureListener + + private inner class DoubleTapListener2 : SimpleOnGestureListener(), InnerListener2 { override fun onDoubleTap(e: MotionEvent): Boolean { doubleTapDetected = true return true @@ -413,7 +468,17 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy) } - private inner class InnerListener3 : SimpleOnGestureListener() { + private inner class RawListener2 : RawListener(), InnerListener2 { + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy) + } + + private interface InnerListener3 : GestureDetector.OnGestureListener + + private inner class DoubleTapListener3 : SimpleOnGestureListener(), InnerListener3 { + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy) + } + + private inner class RawListener3 : RawListener(), InnerListener3 { override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy) } diff --git a/app/src/main/java/com/gaurav/avnc/util/AppPreferences.kt b/app/src/main/java/com/gaurav/avnc/util/AppPreferences.kt index 0f69ab7e..c25acd83 100644 --- a/app/src/main/java/com/gaurav/avnc/util/AppPreferences.kt +++ b/app/src/main/java/com/gaurav/avnc/util/AppPreferences.kt @@ -55,6 +55,7 @@ class AppPreferences(context: Context) { val swipe2; get() = prefs.getString("gesture_swipe2", "pan")!! val swipe3; get() = prefs.getString("gesture_swipe3", "pan")!! val doubleTapSwipe; get() = prefs.getString("gesture_double_tap_swipe", "remote-drag")!! + val doubleClickDetectionEnabled; get() = (doubleTap != "two-left-clicks") val longPressSwipe; get() = prefs.getString("gesture_long_press_swipe", "none")!! val longPressSwipeEnabled; get() = (longPressSwipe != "none" && longPress != "left-press") val longPressDetectionEnabled; get() = (longPress != "none" || longPressSwipeEnabled) @@ -177,4 +178,4 @@ class AppPreferences(context: Context) { putBoolean("run_info_right_meta_keys_migrated", true) } } -} \ No newline at end of file +} From 9b2040ddbc611ca0c5c1e5e970662fde1f4444fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Miret?= Date: Mon, 27 Oct 2025 13:46:47 +0100 Subject: [PATCH 4/6] Fix TouchHandlerTest This test was dependent on the test method execution order. I fix this by ensuring the preferences are reset before each test method. --- .../avnc/ui/vnc/input/TouchHandlerTest.kt | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt b/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt index af5942c3..37fc4fba 100644 --- a/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt +++ b/app/src/androidTest/java/com/gaurav/avnc/ui/vnc/input/TouchHandlerTest.kt @@ -47,18 +47,7 @@ class TouchHandlerTest { private val testPoint = PointF(10f, 10f) @Before - fun setup() { - instrumentation.runOnMainSync { - mockDispatcher = mockk(relaxed = true) - touchHandler = TouchHandler(FrameView(targetConfigContext), mockDispatcher, AppPreferences(targetContext)) - } - - // Internally, mocks seems to be lazily initialized, and the initialization can take some time. - // This is problematic here because gesture detection is very sensitive to timing of events. - // So we eagerly trigger the initialization, to avoid messing with timings in actual tests. - mockDispatcher.onXKey(0, 0, false) - } - + fun setup() = setupWithPref() private fun setupWithPref( mousePassthrough: Boolean = false, @@ -70,7 +59,16 @@ class TouchHandlerTest { putString("gesture_drag", if (dragEnabled) "remote-scroll" else "none") putString("gesture_double_tap", if (doubleClickEnabled) "double-click" else "two-left-clicks") } - setup() + + instrumentation.runOnMainSync { + mockDispatcher = mockk(relaxed = true) + touchHandler = TouchHandler(FrameView(targetConfigContext), mockDispatcher, AppPreferences(targetContext)) + } + + // Internally, mocks seems to be lazily initialized, and the initialization can take some time. + // This is problematic here because gesture detection is very sensitive to timing of events. + // So we eagerly trigger the initialization, to avoid messing with timings in actual tests. + mockDispatcher.onXKey(0, 0, false) } /************************* Finger Gestures *******************************************************/ From 0ef913fa33c75207e769696aff39965dc98963d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Miret?= Date: Mon, 27 Oct 2025 13:58:28 +0100 Subject: [PATCH 5/6] Factorize inner listeners --- .../gaurav/avnc/ui/vnc/input/TouchHandler.kt | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt b/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt index cbb29050..d7e53c57 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt @@ -413,46 +413,21 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis private interface InnerListener1 : GestureDetector.OnGestureListener private inner class DoubleTapListener1 : SimpleOnGestureListener(), InnerListener1 { - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - listener.onSingleTapConfirmed(e) - return true - } + override fun onSingleTapConfirmed(e: MotionEvent): Boolean = handleSingleTap(e) - override fun onLongPress(e: MotionEvent) { - if (!enableLongPress) - return + override fun onLongPress(e: MotionEvent) = handleLongPress(e) - if (doubleTapDetected) - return // Ignore long-press triggered during double-tap-swipe - - longPressDetected = true - listener.onLongPress(e) - } - - override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { - listener.onFling(velocityX, velocityY) - return true - } + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = + handleFling(velocityX, velocityY) } private inner class RawListener1 : RawListener(), InnerListener1 { - override fun onSingleTapUp(e: MotionEvent): Boolean { - listener.onSingleTapConfirmed(e) - return true - } - - override fun onLongPress(e: MotionEvent) { - if (!enableLongPress) - return + override fun onSingleTapUp(e: MotionEvent): Boolean = handleSingleTap(e) - longPressDetected = true - listener.onLongPress(e) - } + override fun onLongPress(e: MotionEvent) = handleLongPress(e) - override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { - listener.onFling(velocityX, velocityY) - return true - } + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = + handleFling(velocityX, velocityY) } private interface InnerListener2 : GestureDetector.OnGestureListener @@ -482,6 +457,27 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy) } + private fun handleLongPress(e: MotionEvent) { + if (!enableLongPress) + return + + if (doubleTapDetected) + return // Ignore long-press triggered during double-tap-swipe + + longPressDetected = true + listener.onLongPress(e) + } + + private fun handleSingleTap(e: MotionEvent): Boolean { + listener.onSingleTapConfirmed(e) + return true + } + + private fun handleFling(velocityX: Float, velocityY: Float): Boolean { + listener.onFling(velocityX, velocityY) + return true + } + private fun handleScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float): Boolean { e1 ?: return false if (!scrolling) { From 8f1012bc2a4571e176c2128d02ed3e15a97dff1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Miret?= Date: Mon, 27 Oct 2025 14:45:51 +0100 Subject: [PATCH 6/6] Add some documentation --- .../gaurav/avnc/ui/vnc/input/TouchHandler.kt | 19 +++++++++++++++++++ app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_input.xml | 6 ++++-- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt b/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt index d7e53c57..9fe6fda8 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/vnc/input/TouchHandler.kt @@ -401,6 +401,11 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis private var cumulatedY = 0f private val multiTapSlopSquare = 30 * 30 + /** + * A [GestureDetector.OnGestureListener] just like + * [SimpleOnGestureListener], except it doesn’t implement + * [GestureDetector.OnDoubleTapListener]. + */ private open class RawListener : GestureDetector.OnGestureListener { override fun onDown(e: MotionEvent): Boolean = false override fun onShowPress(e: MotionEvent) {} @@ -412,6 +417,10 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis private interface InnerListener1 : GestureDetector.OnGestureListener + /** + * [InnerListener1] that waits for single taps to be confirmed, and thus + * can detect double taps. + */ private inner class DoubleTapListener1 : SimpleOnGestureListener(), InnerListener1 { override fun onSingleTapConfirmed(e: MotionEvent): Boolean = handleSingleTap(e) @@ -421,6 +430,10 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis handleFling(velocityX, velocityY) } + /** + * [InnerListener1] that doesn’t wait for single taps to be confirmed, + * and thus can send them immediately to the server. + */ private inner class RawListener1 : RawListener(), InnerListener1 { override fun onSingleTapUp(e: MotionEvent): Boolean = handleSingleTap(e) @@ -432,6 +445,9 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis private interface InnerListener2 : GestureDetector.OnGestureListener + /** + * [InnerListener2] that can detect double taps. + */ private inner class DoubleTapListener2 : SimpleOnGestureListener(), InnerListener2 { override fun onDoubleTap(e: MotionEvent): Boolean { doubleTapDetected = true @@ -443,6 +459,9 @@ class TouchHandler(private val frameView: FrameView, private val dispatcher: Dis override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy) } + /** + * [InnerListener2] that handles double taps as two single taps. + */ private inner class RawListener2 : RawListener(), InnerListener2 { override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy) } diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 641b7089..6a5d60ff 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -154,6 +154,7 @@ Par défaut Sensibilité du défilement Écran tactile\nEffectuer les actions au point de contact\n\nPavé tactile\nEffectuer les actions au niveau du pointeur + Aucun\nNe rien faire quand un appui double est détecté.\n\nDeux clics gauches\nNe pas détecter les appuis doubles, ce qui permet d’envoyer immédiatement les appuis simples au serveur. L\'attribution d\'une action à ce geste modifiera la détection de la pression longue : \n \nAppuyer-tenir-lâcher → Appui long diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81ec2029..4ccc6e42 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Delaying click events can help in some rare cases if an app is not responding to clicks. Wake-on-LAN can be used to remotely power-on a computer.\n\nFirst, configure WoL on remote computer, then enable it in AVNC.\nOnce enabled, WoL magic packet will be automatically sent before connecting to this server. Touchscreen\nDo actions at touch-point \n\nTouchpad\nDo actions at pointer + None\nDon’t do anything when a double-tap is detected.\n\nTwo left-clicks\nDo not detect double taps, which allows sending single taps immediately to the server. Assigning an action to this gesture will change Long press detection:\n\nPress-hold-release → Long press\nPress-hold-swipe → Long press and swipe This server has been deleted Server list is empty.\nClick \'+\' to add a server, or\nuse the top address bar to connect directly. diff --git a/app/src/main/res/xml/pref_input.xml b/app/src/main/res/xml/pref_input.xml index dc979997..43990bd6 100644 --- a/app/src/main/res/xml/pref_input.xml +++ b/app/src/main/res/xml/pref_input.xml @@ -24,12 +24,14 @@ app:useSimpleSummaryProvider="true" app:widgetLayout="@layout/help_btn" /> - - \ No newline at end of file +