diff --git a/apps/android-viewer/app/build.gradle.kts b/apps/android-viewer/app/build.gradle.kts index 03785dc..ef7f8f9 100644 --- a/apps/android-viewer/app/build.gradle.kts +++ b/apps/android-viewer/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.verify.remote" minSdk = 26 targetSdk = 34 - versionCode = 2 - versionName = "0.1-phase3a" + versionCode = 3 + versionName = "0.1-phase3b" // 阶段 1 只测真机 arm64;x86_64 留给模拟器回归 ndk { abiFilters += listOf("arm64-v8a") diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt index 5bc958f..5c4b0c8 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt @@ -45,6 +45,10 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { private lateinit var codeInput: EditText private lateinit var connectButton: Button private lateinit var disconnectButton: Button + private lateinit var modeButton: Button + private lateinit var sensUpButton: Button + private lateinit var sensDownButton: Button + private lateinit var sensLabel: TextView private lateinit var connectStatus: TextView @Volatile @@ -97,6 +101,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { orientation = LinearLayout.VERTICAL setBackgroundColor(0xFF000000.toInt()) } + // 3b:模式与灵敏度先于面板构造恢复——buildRemotePanel 里的按钮文案要读它们 + surfaceView.relativeMode = prefs().getBoolean(PREF_RELATIVE, true) + surfaceView.sensitivity = prefs().getFloat(PREF_SENSITIVITY, RelPointer.DEFAULT_SENSITIVITY) connectPanel = buildConnectPanel() remotePanel = buildRemotePanel() val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f) @@ -208,6 +215,32 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { text = "键盘" setOnClickListener { showKeyboard() } } + // 协议 v4:指针模式切换(Q3=A:默认相对,可切绝对) + modeButton = Button(this).apply { + text = modeLabel() + setOnClickListener { + surfaceView.relativeMode = !surfaceView.relativeMode + prefs().edit().putBoolean(PREF_RELATIVE, surfaceView.relativeMode).apply() + text = modeLabel() + lastTouchDbg = if (surfaceView.relativeMode) "模式:相对" else "模式:绝对" + refreshStatus() + } + } + // 灵敏度(Q4=A:0.25–3.0,默认 1.0)。仅相对模式生效,绝对模式下调了也不影响。 + sensDownButton = Button(this).apply { + text = "−" + setOnClickListener { adjustSensitivity(-SENS_STEP) } + } + sensUpButton = Button(this).apply { + text = "+" + setOnClickListener { adjustSensitivity(+SENS_STEP) } + } + sensLabel = TextView(this).apply { + text = sensText() + textSize = 12f + setTextColor(0xFFFFFFFF.toInt()) + gravity = Gravity.CENTER + } disconnectButton = Button(this).apply { text = "断开" // 连接等待期它显示「取消」并走同一个 cancelConnect():两个按钮的 @@ -217,8 +250,15 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { val bar = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + addView(modeButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) addView(disconnectButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) } + val sensBar = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + addView(sensDownButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + addView(sensLabel, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + addView(sensUpButton, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + } return LinearLayout(this).apply { orientation = LinearLayout.VERTICAL visibility = View.GONE @@ -226,10 +266,26 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { // 固定高度而非 WRAP_CONTENT:与 weight=1 的兄弟共存时, // WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见) addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) + addView(sensBar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) } } + private fun prefs() = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private fun modeLabel() = if (surfaceView.relativeMode) "模式:相对" else "模式:绝对" + + private fun sensText() = "灵敏度 ${"%.2f".format(surfaceView.sensitivity)}" + + /** 灵敏度按 0.25 步进调整并落盘(绝对模式下调了也不影响,切回相对即生效) */ + private fun adjustSensitivity(delta: Float) { + surfaceView.sensitivity = surfaceView.sensitivity + delta + prefs().edit().putFloat(PREF_SENSITIVITY, surfaceView.sensitivity).apply() + sensLabel.text = sensText() + lastTouchDbg = "灵敏度 ${"%.2f".format(surfaceView.sensitivity)}" + refreshStatus() + } + private fun showConnectState(msg: String) { connecting = false remotePanelVisible(false) @@ -239,6 +295,36 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { private fun showRemoteState() { remotePanelVisible(true) connectPanel.visibility = View.GONE + // 3b/N17(Q6=A):表单被隐藏时必须主动清焦点。否则 EditText 仍持有焦点, + // 远控面板上的按钮第一下 tap 只被用来「丢焦点」而不触发点击——真机上表现 + // 为「按钮要点两次」(3a 的 D8 已实测到 4 轮全丢 tap)。 + clearFormFocus() + } + + /** + * 收走表单输入框的焦点并隐藏软键盘。 + * + * 为什么放在切面板时而不是按钮点击时:点按钮时焦点已经丢了,补救太晚; + * 面板可见的那一刻才是「表单不再需要输入」的准确时机。 + * + * **3b 真机暴露的加强(N19)**:只 `clearFocus()` 不够。实测远控面板已经显示、 + * 表单已 GONE 之后,`dumpsys input_method` 里 `mServedView` **仍指向表单的 + * EditText**、`mInputShown=true`——软键盘继续挂在屏幕底部,把面板底部的按钮栏 + * (键盘/模式/断开,y=2256..2324)整个盖住,**所有按钮点不动**。 + * 因此必须显式对每个表单输入框 `hideSoftInputFromWindow`,并确认 IME 真的收起。 + */ + private fun clearFormFocus() { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + // 逐个表单输入框收键盘:currentFocus 可能已经不是它们(隐藏视图的焦点 + // 状态不可靠),而 IME 的 mServedView 恰恰还绑在它们身上。 + for (v in listOf(hostInput, portInput, codeInput)) { + v.clearFocus() + imm.hideSoftInputFromWindow(v.windowToken, 0) + } + currentFocus?.let { if (it !== surfaceView) it.clearFocus() } + keyboardVisible = false + // 布局变化会再触发 updateKeyboardVisibility(),用真实可见区域复核一次 + window.decorView.post { updateKeyboardVisibility() } } private fun remotePanelVisible(show: Boolean) { @@ -276,6 +362,12 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { if (h != 0L) RustBridge.nativeSendMouseMove(h, x, y) } + /** 协议 v4:相对位移(被控端桌面物理像素)。零位移由 Rust 侧拦掉,这里不重复判断。 */ + override fun sendMouseRel(dx: Int, dy: Int) { + val h = handle + if (h != 0L) RustBridge.nativeSendMouseRel(h, dx, dy) + } + override fun sendMouseButton(button: Int, down: Boolean) { val h = handle if (h != 0L) RustBridge.nativeSendMouseButton(h, button, if (down) 1 else 0) @@ -301,6 +393,22 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { override fun isReadyForInput(): Boolean = handle != 0L + /** + * 3b:右键注入计数(长按 / 双指各一路)。持久计数而不是覆盖式诊断行—— + * 长按之后的 ACTION_UP 会把 lastTouchDbg 改写成「长按结束」,验收脚本读不到 + * 「长按右键」。计数不会丢,且能区分两种来源。 + */ + @Volatile + private var rightClicks = 0 + @Volatile + private var rightClickSource = "-" + + override fun onRightClickInjected(source: String) { + rightClicks++ + rightClickSource = source + runOnUiThread { refreshStatus() } + } + /** * 软键盘是否可见(自测,不依赖 InputMethodManager 的间接信号)。 * @@ -492,6 +600,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { handle = h remoteW = w remoteH = ht + // 相对模式的换算基准:视图像素 → 桌面像素必须知道远端真实尺寸 + surfaceView.remoteWidth = w + surfaceView.remoteHeight = ht connectButton.text = "断开" disconnectButton.text = "断开" // 只有认证成功的会话才值得记住;验证码不落盘(用户裁决) @@ -623,6 +734,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { if (handle == h) handle = 0L remoteW = 0 remoteH = 0 + // 断开后相对换算失去基准:不清零会让下一次会话在拿到新尺寸前按旧尺寸换算 + surfaceView.remoteWidth = 0 + surfaceView.remoteHeight = 0 lastFrames = 0 lastRtt = 0 lastFps = 0 @@ -647,6 +761,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { decodeThread = null remoteW = 0 remoteH = 0 + surfaceView.remoteWidth = 0 + surfaceView.remoteHeight = 0 lastFrames = 0 lastRtt = 0 lastFps = 0 @@ -677,7 +793,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { } statusView.text = buildString { append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH fps=$lastFps RTT=${lastRtt}ms 帧=$lastFrames\n") - append("输入: $lastTouchDbg") + append("${if (surfaceView.relativeMode) "相对" else "绝对"} | 输入: $lastTouchDbg") + // 右键计数只在发生过时出现,避免常态占宽(状态栏只有 3 行) + if (rightClicks > 0) append(" | 右键=$rightClicks($rightClickSource)") } } @@ -686,12 +804,17 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { companion object { private const val TAG = "MainActivity" private const val PREFS = "connect" + /** 3b:指针模式与灵敏度落盘(验证码仍不落盘,见 2b 裁决) */ + private const val PREF_RELATIVE = "relative_mode" + private const val PREF_SENSITIVITY = "sensitivity" + private const val SENS_STEP = 0.25f /** * 与协议版本保持一致:远控画面状态栏显示当前握手版本(方案 R5 要求)。 * 值随 `common::PROTO_VERSION` 人工同步——Android 侧不做线格式解析, * 版本由 Rust 握手校验,这里只作显示与排障用。 + * 3b:3 → 4(协议 v4 相对鼠标)。 */ - private const val PROTO_LABEL = "3" + private const val PROTO_LABEL = "4" } } diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RelPointer.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RelPointer.kt new file mode 100644 index 0000000..19096c9 --- /dev/null +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RelPointer.kt @@ -0,0 +1,119 @@ +package com.verify.remote + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * 相对鼠标的**纯逻辑**:把「手指在视图上移动了多少像素」换算成「远端指针该走多少桌面像素」, + * 并做小数累积取整。抽成纯 Kotlin 是为了能在 JVM 单测里验证(V-B1 的判据依赖它)。 + * + * 为什么不直接发 `deltaViewPx`: + * - 视图尺寸 ≠ 桌面尺寸(letterbox 后视图是 1080x607,桌面是 1920x1080); + * - `sensitivity != 1.0` 时还要缩放。 + * 两步合并成一次乘法,避免分两次取整(每多一次取整就多一个累积误差源)。 + * + * 为什么不直接 `toInt()` 就发: + * 手指慢速滑动时每次 `ACTION_MOVE` 的增量可能只有 0.3 个桌面像素,`toInt()` 逐次截断会让 + * **位移被静默吞掉**(V-B1「慢速小幅滑动不丢位移」直接不成立)。必须把余数累积到下一次。 + */ +object RelPointer { + + /** 灵敏度允许范围(Q4=A:默认 1.0,可调 0.25–3.0) */ + const val MIN_SENSITIVITY = 0.25f + const val MAX_SENSITIVITY = 3.0f + const val DEFAULT_SENSITIVITY = 1.0f + + fun clampSensitivity(v: Float): Float = v.coerceIn(MIN_SENSITIVITY, MAX_SENSITIVITY) + + /** + * 视图像素增量 → 桌面像素增量(**浮点,未取整**)。 + * + * `sensitivity = 1.0` 的语义:手指扫过视图整个宽度 = 指针走完整个桌面宽度。 + * 这样相对模式与绝对模式的手感一致(绝对模式下 10% 视图宽 = 10% 桌面宽)。 + * + * 任一维度非法(<=0,例如视图尚未测量、远端尺寸未取到)时返回 0: + * 宁可不移动,也不要按错误比例乱飞。 + */ + fun toDesktopDelta( + viewDeltaPx: Float, + viewExtentPx: Int, + remoteExtentPx: Int, + sensitivity: Float, + ): Float { + if (viewExtentPx <= 0 || remoteExtentPx <= 0) return 0f + if (viewDeltaPx == 0f) return 0f + val scale = remoteExtentPx.toFloat() / viewExtentPx.toFloat() + return viewDeltaPx * scale * clampSensitivity(sensitivity) + } +} + +/** + * 小数累积器:把连续的浮点位移攒成整数像素再发送,余数留到下一次。 + * + * 保证:连续输入之和与单独一次输入等价(不丢位移);每次发出的都是整数(协议是 i16)。 + * 撞边等外部原因丢弃的位移**不回补**——累积器只负责「取整」,不负责「补偿」, + * 否则撞边后回滑会出现一段"空转"(V-B1 第三条判据要求撞边后立即响应)。 + */ +class RelAccumulator { + private var accX = 0f + private var accY = 0f + + /** 会话/手势结束时清空,避免上一段手势的余数漏到下一段 */ + fun reset() { + accX = 0f + accY = 0f + } + + /** + * 累积一段位移,返回**本次可以发送的整数增量**;不足 1 像素时返回 null(不发)。 + * + * X/Y 独立累积:斜向慢速滑动时两个轴各自攒够才发,不会因为一个轴没到 1 就把另一个轴也拖住。 + */ + fun take(dxDesktop: Float, dyDesktop: Float): Pair? { + accX += dxDesktop + accY += dyDesktop + // roundToInt 而非 toInt:-0.7 应取 -1 而不是 0(向零截断会让反向慢滑丢位移) + val ix = accX.roundToInt() + val iy = accY.roundToInt() + if (ix == 0 && iy == 0) return null + accX -= ix + accY -= iy + // 协议是 i16;单次事件不可能接近这个范围,但显式 clamp 让越界行为有界 + return ix.coerceIn(-32768, 32767) to iy.coerceIn(-32768, 32767) + } +} + +/** + * 长按右键的判定(Q5=A:≥500ms 且位移 < slop)。 + * + * 抽成纯逻辑的原因:真实触发依赖 Handler 计时与 MotionEvent,JVM 里不可构造; + * 但「什么条件下该弹右键」是纯判断,必须能被单测钉住(尤其"拖窗口不弹菜单"这条)。 + */ +object LongPressPolicy { + + /** 长按触发阈值(毫秒) */ + const val LONG_PRESS_MS = 500L + + /** + * 是否应触发长按右键。 + * + * @param elapsedMs 从 ACTION_DOWN 起经过的时间 + * @param movedPx 期间的最大位移(欧氏距离) + * @param slopPx 拖动阈值(12dp),超过即视为拖拽而非长按 + * @param twoFinger 是否出现过第二根手指(双指手势有自己的右键路径,不能叠加) + * + * 三条都必须满足:够久、没怎么动、不是双指。 + * 「拖窗口不弹菜单」靠 movedPx 这条——超过 slop 时 [RemoteSurfaceView] 会切到 DRAG + * 并取消计时任务,所以这里通常压根不会被调用;但作为纯逻辑仍要自洽。 + */ + fun shouldFire( + elapsedMs: Long, + movedPx: Float, + slopPx: Float, + twoFinger: Boolean, + ): Boolean { + if (twoFinger) return false + if (elapsedMs < LONG_PRESS_MS) return false + return abs(movedPx) < slopPx + } +} diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt index be5f7f7..f4edefd 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt @@ -1,6 +1,8 @@ package com.verify.remote import android.content.Context +import android.os.Handler +import android.os.Looper import android.text.InputType import android.view.MotionEvent import android.view.SurfaceView @@ -21,10 +23,15 @@ import kotlin.math.hypot * 实现为本项目独立编写): * - 单指轻点 = 左键单击 * - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中) + * - 单指长按不动 = 右键(协议 v4 起;双指轻点仍可用) * - 双指轻点 = 右键 * - 双指上下滑动 = 滚轮 * - * 刻意不做「手指即光标」的绝对定位:手指会完全挡住目标,桌面精细操作不可用。 + * **两种指针模式(协议 v4)**: + * - 相对模式(默认):手指滑动 = 远端指针按**增量**移动,手指只扫一小段就能走很远, + * 且不遮挡目标。DOWN 不发送绝对移动——轻点落在**远端指针当前位置**。 + * - 绝对模式:手指位置直接映射到远端坐标(2a/3a 行为,DOWN 先发绝对 `MouseMove`)。 + * 两种模式由 [relativeMode] 切换;R7 绝对触摸回归判据依赖绝对模式仍可用。 * * **IME 接线(阶段 2b)**:本视图是文本编辑器(`onCheckIsTextEditor`), * 软键盘的提交经 `RemoteInputConnection` 上行。`isFocusableInTouchMode` 必须为 @@ -36,6 +43,8 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { /** 由 Activity 实现,转发到 RustBridge */ interface Listener { fun sendMouseMove(x: Int, y: Int) + /** 协议 v4:相对位移(被控端桌面物理像素) */ + fun sendMouseRel(dx: Int, dy: Int) fun sendMouseButton(button: Int, down: Boolean) fun sendWheel(dy: Int) /** 协议 v3:一段 UTF-16 code unit(IME 提交结果) */ @@ -44,10 +53,41 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { fun sendKey(key: WinKey, down: Boolean) /** 连接态:只有已连接才声明自己是文本编辑器,避免未连接时误弹键盘 */ fun isReadyForInput(): Boolean + /** + * 右键注入发生了(3b)。用于把「长按是否真的发了右键」变成**可持久观测**的状态。 + * + * 为什么需要它:`onDebug` 是覆盖式的一行文本,长按后紧跟着的 ACTION_UP 会 + * 立刻把它改写成「长按结束」——真机验收脚本因此看不到「长按右键」, + * 判据被后续事件冲掉。计数器不会丢。 + */ + fun onRightClickInjected(source: String) } var listener: Listener? = null + /** + * 相对指针模式(协议 v4)。默认 true:桌面远控的通行形态,手指不遮目标。 + * 切换时清空累积器——上一模式的余数不该漏进新模式。 + */ + var relativeMode: Boolean = true + set(value) { + if (field != value) { + field = value + relAcc.reset() + } + } + + /** 灵敏度(仅相对模式生效)。默认 1.0 = 手指扫过视图全宽,指针走完桌面全宽。 */ + var sensitivity: Float = RelPointer.DEFAULT_SENSITIVITY + set(value) { + field = RelPointer.clampSensitivity(value) + relAcc.reset() + } + + /** 远端桌面像素尺寸(相对模式换算用);未连接时为 0,此时相对移动不发送 */ + var remoteWidth: Int = 0 + var remoteHeight: Int = 0 + /** 视频宽高比(w/h)。>0 时视图按此比例测量。 */ var videoAspectRatio = 0f set(value) { @@ -70,6 +110,14 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { private var twoFingerMoved = false private var wheelAccum = 0f + /** 相对模式的小数累积器(慢速滑动不丢位移) */ + private val relAcc = RelAccumulator() + + /** 长按右键:计时任务 + 是否已消费本次手势的 UP(防「先右键再左键」) */ + private val ui = Handler(Looper.getMainLooper()) + private var longPressFired = false + private var longPressTwoFinger = false + /** 判定「拖拽 vs 点击」的位移阈值:12dp */ private val dragSlopPx = resources.displayMetrics.density * 12f @@ -79,6 +127,32 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { isFocusableInTouchMode = true } + /** + * 长按计时任务(Q5=A:≥500ms 且位移 < slop)。 + * 命名刻意避开 `cancelLongPress`——`View` 已有同名方法,会被要求 override。 + */ + private val longPressTask = Runnable { + val l = listener ?: return@Runnable + if (mode != Mode.TAP) return@Runnable // 已转拖拽/双指 + val elapsed = System.currentTimeMillis() - downTime + val movedPx = hypot((lastX - downX).toDouble(), (lastY - downY).toDouble()).toFloat() + if (!LongPressPolicy.shouldFire(elapsed, movedPx, dragSlopPx, longPressTwoFinger)) { + return@Runnable + } + // 相对模式:在远端指针**当前位置**点右键,不发绝对移动 + if (!relativeMode) l.sendMouseMove(normX(lastX), normY(lastY)) + l.sendMouseButton(BTN_RIGHT, true) + l.sendMouseButton(BTN_RIGHT, false) + longPressFired = true + performClick() + onDebug?.invoke("长按右键") + listener?.onRightClickInjected("长按") + } + + private fun cancelLongPressTimer() { + ui.removeCallbacks(longPressTask) + } + // ---- IME 通道 ---- override fun onCheckIsTextEditor(): Boolean = listener?.isReadyForInput() == true @@ -135,6 +209,25 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { private fun normX(x: Float) = (x / width * 65535).toInt().coerceIn(0, 65535) private fun normY(y: Float) = (y / height * 65535).toInt().coerceIn(0, 65535) + /** + * 相对模式下把视图像素增量发出去(经累积取整)。 + * + * 为什么 TAP/DRAG 都走这一个函数:两个分支都必须用**同一个**累积器, + * 分成两套会让 TAP→DRAG 切换时丢一次余数。 + */ + private fun sendRelative(l: Listener, fromX: Float, fromY: Float, toX: Float, toY: Float) { + val dx = RelPointer.toDesktopDelta(toX - fromX, width, remoteWidth, sensitivity) + val dy = RelPointer.toDesktopDelta(toY - fromY, height, remoteHeight, sensitivity) + if (dx == 0f && dy == 0f) return + val d = relAcc.take(dx, dy) ?: return + l.sendMouseRel(d.first, d.second) + } + + /** 绝对模式下按当前手指位置发归一化坐标 */ + private fun sendAbsolute(l: Listener, x: Float, y: Float) { + l.sendMouseMove(normX(x), normY(y)) + } + private fun moved(fromX: Float, fromY: Float, toX: Float, toY: Float) = hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx @@ -166,8 +259,15 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { lastX = event.x; lastY = event.y downTime = System.currentTimeMillis() mode = Mode.TAP - // 先把远端指针移到位,点击/拖拽都以此为基准 - l.sendMouseMove(normX(event.x), normY(event.y)) + longPressFired = false + longPressTwoFinger = false + relAcc.reset() + // 相对模式:**不发**绝对移动。手指落下不该让远端指针跳到自己脚下 + // (那正是相对鼠标要解决的问题);轻点落在指针当前位置。 + if (!relativeMode) sendAbsolute(l, event.x, event.y) + // 长按右键计时(Q5=A:500ms 且不动) + cancelLongPressTimer() + ui.postDelayed(longPressTask, LongPressPolicy.LONG_PRESS_MS) onDebug?.invoke("按下") } @@ -176,6 +276,8 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { // 必须先抬起,否则远端会留下一个被按住未释放的左键。 if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false) mode = Mode.TWO_FINGER + longPressTwoFinger = true + cancelLongPressTimer() twoFingerMoved = false wheelAccum = 0f twoFingerStartY = midY(event) @@ -186,16 +288,28 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { MotionEvent.ACTION_MOVE -> when (mode) { Mode.TAP -> if (moved(downX, downY, event.x, event.y)) { + // 超过 slop = 拖拽,长按不再可能(先取消计时,避免拖到一半弹菜单) + cancelLongPressTimer() mode = Mode.DRAG l.sendMouseButton(BTN_LEFT, true) + if (relativeMode) { + // 相对拖拽:从落点开始按增量移动(不先跳到手指位置) + relAcc.reset() + sendRelative(l, downX, downY, event.x, event.y) + } else { + sendAbsolute(l, event.x, event.y) + } lastX = event.x; lastY = event.y - l.sendMouseMove(normX(event.x), normY(event.y)) onDebug?.invoke("拖拽中") } Mode.DRAG -> { + if (relativeMode) { + sendRelative(l, lastX, lastY, event.x, event.y) + } else { + sendAbsolute(l, event.x, event.y) + } lastX = event.x; lastY = event.y - l.sendMouseMove(normX(event.x), normY(event.y)) } Mode.TWO_FINGER -> { @@ -226,13 +340,22 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { } MotionEvent.ACTION_UP -> { + cancelLongPressTimer() when (mode) { Mode.TAP -> { - // 轻点 = 左键单击(位置已在 DOWN 时发送) - l.sendMouseButton(BTN_LEFT, true) - l.sendMouseButton(BTN_LEFT, false) - performClick() - onDebug?.invoke("单击") + if (longPressFired) { + // 长按已弹右键:本次 UP 不能再发左键单击, + // 否则一次长按会「先右键再左键」,右键菜单立刻被左键点掉 + onDebug?.invoke("长按结束") + } else { + // 轻点 = 左键单击。绝对模式位置已在 DOWN 时发送; + // 相对模式点击**远端指针当前位置**,不发任何移动 + if (!relativeMode) sendAbsolute(l, lastX, lastY) + l.sendMouseButton(BTN_LEFT, true) + l.sendMouseButton(BTN_LEFT, false) + performClick() + onDebug?.invoke("单击") + } } Mode.DRAG -> { l.sendMouseButton(BTN_LEFT, false) @@ -244,11 +367,14 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { Mode.IDLE -> {} } mode = Mode.IDLE + longPressFired = false } MotionEvent.ACTION_CANCEL -> { + cancelLongPressTimer() if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false) mode = Mode.IDLE + longPressFired = false onDebug?.invoke("手势取消") } } @@ -259,10 +385,13 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) { private fun maybeRightClick(l: Listener): Boolean { if (twoFingerMoved) return false if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return false - l.sendMouseMove(normX(lastX), normY(lastY)) + // 双指右键沿用「先定位再点」:双指落点本身就是用户的定位意图, + // 与单指相对模式「不动指针」的语义不同(2a 起就是这个行为,回归判据依赖它) + sendAbsolute(l, lastX, lastY) l.sendMouseButton(BTN_RIGHT, true) l.sendMouseButton(BTN_RIGHT, false) performClick() + listener?.onRightClickInjected("双指") return true } diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt index ae524f8..af47943 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt @@ -36,6 +36,11 @@ object RustBridge { external fun nativeStats(handle: Long): Long external fun nativeDesktopSize(handle: Long): Long external fun nativeSendMouseMove(handle: Long, x: Int, y: Int) + /** + * 协议 v4:相对移动(单位 = 被控端桌面物理像素)。 + * Rust 侧把 Int clamp 到 i16,越界不会回绕;零位移在 viewer-core 被拦掉。 + */ + external fun nativeSendMouseRel(handle: Long, dx: Int, dy: Int) external fun nativeSendMouseButton(handle: Long, button: Int, down: Int) external fun nativeSendWheel(handle: Long, dy: Int) external fun nativeSendKey(handle: Long, scancode: Int, extended: Int, down: Int)