feat(android): 相对鼠标模式 + 灵敏度 + 长按右键 + 修复表单 IME 残留

相对模式(默认):手指滑动按增量移动远端指针,DOWN 不发绝对移动——轻点落在
远端指针当前位置,不再「手指落下指针跳到脚下」。换算基准为视图全宽=桌面全宽
(灵敏度 1.0),慢速滑动用小数累积取整,逐次 toInt() 会静默吞掉位移。

长按右键(Q5=A):>=500ms 且位移 <12dp;触发后抑制本次 ACTION_UP 的左键,
否则一次长按会「先右键再左键」把菜单立刻点掉。超过 slop 立即转拖拽并取消计时,
所以拖窗口不会弹菜单。双指右键路径保持不变。

真机暴露并修复(N19):切到远控面板后软键盘仍绑在已隐藏的表单 EditText 上
(dumpsys 里 mServedView 指向 EditText、mInputShown=true),键盘盖住面板底部
按钮栏,**所有按钮点不动**——3b 新增的模式/灵敏度按钮把它暴露出来。改为对每个
表单输入框显式 hideSoftInputFromWindow。

新增右键计数器:长按后紧跟的 ACTION_UP 会覆盖一行式诊断文本,验收脚本读不到
「长按右键」;计数器持久且能区分长按/双指来源。
This commit is contained in:
郑博文 2026-10-31 17:35:11 +08:00
parent 3773a0851c
commit 7c3b6b3e93
5 changed files with 391 additions and 15 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.verify.remote" applicationId = "com.verify.remote"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 2 versionCode = 3
versionName = "0.1-phase3a" versionName = "0.1-phase3b"
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归 // 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
ndk { ndk {
abiFilters += listOf("arm64-v8a") abiFilters += listOf("arm64-v8a")

View File

@ -45,6 +45,10 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
private lateinit var codeInput: EditText private lateinit var codeInput: EditText
private lateinit var connectButton: Button private lateinit var connectButton: Button
private lateinit var disconnectButton: 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 private lateinit var connectStatus: TextView
@Volatile @Volatile
@ -97,6 +101,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setBackgroundColor(0xFF000000.toInt()) setBackgroundColor(0xFF000000.toInt())
} }
// 3b:模式与灵敏度先于面板构造恢复——buildRemotePanel 里的按钮文案要读它们
surfaceView.relativeMode = prefs().getBoolean(PREF_RELATIVE, true)
surfaceView.sensitivity = prefs().getFloat(PREF_SENSITIVITY, RelPointer.DEFAULT_SENSITIVITY)
connectPanel = buildConnectPanel() connectPanel = buildConnectPanel()
remotePanel = buildRemotePanel() remotePanel = buildRemotePanel()
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f) val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
@ -208,6 +215,32 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
text = "键盘" text = "键盘"
setOnClickListener { showKeyboard() } 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 { disconnectButton = Button(this).apply {
text = "断开" text = "断开"
// 连接等待期它显示「取消」并走同一个 cancelConnect():两个按钮的 // 连接等待期它显示「取消」并走同一个 cancelConnect():两个按钮的
@ -217,8 +250,15 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
val bar = LinearLayout(this).apply { val bar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) addView(keyboardButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
addView(modeButton, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
addView(disconnectButton, 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 { return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
visibility = View.GONE visibility = View.GONE
@ -226,10 +266,26 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
// 固定高度而非 WRAP_CONTENT:与 weight=1 的兄弟共存时, // 固定高度而非 WRAP_CONTENT:与 weight=1 的兄弟共存时,
// WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见) // WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见)
addView(statusView, LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) 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)) 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) { private fun showConnectState(msg: String) {
connecting = false connecting = false
remotePanelVisible(false) remotePanelVisible(false)
@ -239,6 +295,36 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
private fun showRemoteState() { private fun showRemoteState() {
remotePanelVisible(true) remotePanelVisible(true)
connectPanel.visibility = View.GONE 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) { private fun remotePanelVisible(show: Boolean) {
@ -276,6 +362,12 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
if (h != 0L) RustBridge.nativeSendMouseMove(h, x, y) 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) { override fun sendMouseButton(button: Int, down: Boolean) {
val h = handle val h = handle
if (h != 0L) RustBridge.nativeSendMouseButton(h, button, if (down) 1 else 0) 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 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 的间接信号)。 * 软键盘是否可见(自测,不依赖 InputMethodManager 的间接信号)。
* *
@ -492,6 +600,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
handle = h handle = h
remoteW = w remoteW = w
remoteH = ht remoteH = ht
// 相对模式的换算基准:视图像素 → 桌面像素必须知道远端真实尺寸
surfaceView.remoteWidth = w
surfaceView.remoteHeight = ht
connectButton.text = "断开" connectButton.text = "断开"
disconnectButton.text = "断开" disconnectButton.text = "断开"
// 只有认证成功的会话才值得记住;验证码不落盘(用户裁决) // 只有认证成功的会话才值得记住;验证码不落盘(用户裁决)
@ -623,6 +734,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
if (handle == h) handle = 0L if (handle == h) handle = 0L
remoteW = 0 remoteW = 0
remoteH = 0 remoteH = 0
// 断开后相对换算失去基准:不清零会让下一次会话在拿到新尺寸前按旧尺寸换算
surfaceView.remoteWidth = 0
surfaceView.remoteHeight = 0
lastFrames = 0 lastFrames = 0
lastRtt = 0 lastRtt = 0
lastFps = 0 lastFps = 0
@ -647,6 +761,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
decodeThread = null decodeThread = null
remoteW = 0 remoteW = 0
remoteH = 0 remoteH = 0
surfaceView.remoteWidth = 0
surfaceView.remoteHeight = 0
lastFrames = 0 lastFrames = 0
lastRtt = 0 lastRtt = 0
lastFps = 0 lastFps = 0
@ -677,7 +793,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
} }
statusView.text = buildString { statusView.text = buildString {
append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH fps=$lastFps RTT=${lastRtt}ms 帧=$lastFrames\n") 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 { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
private const val PREFS = "connect" 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 要求)。 * 与协议版本保持一致:远控画面状态栏显示当前握手版本(方案 R5 要求)。
* 值随 `common::PROTO_VERSION` 人工同步——Android 侧不做线格式解析, * 值随 `common::PROTO_VERSION` 人工同步——Android 侧不做线格式解析,
* 版本由 Rust 握手校验,这里只作显示与排障用。 * 版本由 Rust 握手校验,这里只作显示与排障用。
* 3b:3 → 4(协议 v4 相对鼠标)。
*/ */
private const val PROTO_LABEL = "3" private const val PROTO_LABEL = "4"
} }
} }

View File

@ -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<Int, Int>? {
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
}
}

View File

@ -1,6 +1,8 @@
package com.verify.remote package com.verify.remote
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.text.InputType import android.text.InputType
import android.view.MotionEvent import android.view.MotionEvent
import android.view.SurfaceView import android.view.SurfaceView
@ -21,10 +23,15 @@ import kotlin.math.hypot
* 实现为本项目独立编写): * 实现为本项目独立编写):
* - 单指轻点 = 左键单击 * - 单指轻点 = 左键单击
* - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中) * - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中)
* - 单指长按不动 = 右键(协议 v4 起;双指轻点仍可用)
* - 双指轻点 = 右键 * - 双指轻点 = 右键
* - 双指上下滑动 = 滚轮 * - 双指上下滑动 = 滚轮
* *
* 刻意不做「手指即光标」的绝对定位:手指会完全挡住目标,桌面精细操作不可用。 * **两种指针模式(协议 v4)**:
* - 相对模式(默认):手指滑动 = 远端指针按**增量**移动,手指只扫一小段就能走很远,
* 且不遮挡目标。DOWN 不发送绝对移动——轻点落在**远端指针当前位置**。
* - 绝对模式:手指位置直接映射到远端坐标(2a/3a 行为,DOWN 先发绝对 `MouseMove`)。
* 两种模式由 [relativeMode] 切换;R7 绝对触摸回归判据依赖绝对模式仍可用。
* *
* **IME 接线(阶段 2b)**:本视图是文本编辑器(`onCheckIsTextEditor`), * **IME 接线(阶段 2b)**:本视图是文本编辑器(`onCheckIsTextEditor`),
* 软键盘的提交经 `RemoteInputConnection` 上行。`isFocusableInTouchMode` 必须为 * 软键盘的提交经 `RemoteInputConnection` 上行。`isFocusableInTouchMode` 必须为
@ -36,6 +43,8 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
/** 由 Activity 实现,转发到 RustBridge */ /** 由 Activity 实现,转发到 RustBridge */
interface Listener { interface Listener {
fun sendMouseMove(x: Int, y: Int) fun sendMouseMove(x: Int, y: Int)
/** 协议 v4:相对位移(被控端桌面物理像素) */
fun sendMouseRel(dx: Int, dy: Int)
fun sendMouseButton(button: Int, down: Boolean) fun sendMouseButton(button: Int, down: Boolean)
fun sendWheel(dy: Int) fun sendWheel(dy: Int)
/** 协议 v3:一段 UTF-16 code unit(IME 提交结果) */ /** 协议 v3:一段 UTF-16 code unit(IME 提交结果) */
@ -44,10 +53,41 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
fun sendKey(key: WinKey, down: Boolean) fun sendKey(key: WinKey, down: Boolean)
/** 连接态:只有已连接才声明自己是文本编辑器,避免未连接时误弹键盘 */ /** 连接态:只有已连接才声明自己是文本编辑器,避免未连接时误弹键盘 */
fun isReadyForInput(): Boolean fun isReadyForInput(): Boolean
/**
* 右键注入发生了(3b)。用于把「长按是否真的发了右键」变成**可持久观测**的状态。
*
* 为什么需要它:`onDebug` 是覆盖式的一行文本,长按后紧跟着的 ACTION_UP 会
* 立刻把它改写成「长按结束」——真机验收脚本因此看不到「长按右键」,
* 判据被后续事件冲掉。计数器不会丢。
*/
fun onRightClickInjected(source: String)
} }
var listener: Listener? = null 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 时视图按此比例测量。 */ /** 视频宽高比(w/h)。>0 时视图按此比例测量。 */
var videoAspectRatio = 0f var videoAspectRatio = 0f
set(value) { set(value) {
@ -70,6 +110,14 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
private var twoFingerMoved = false private var twoFingerMoved = false
private var wheelAccum = 0f 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 */ /** 判定「拖拽 vs 点击」的位移阈值:12dp */
private val dragSlopPx = resources.displayMetrics.density * 12f private val dragSlopPx = resources.displayMetrics.density * 12f
@ -79,6 +127,32 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
isFocusableInTouchMode = true 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 通道 ---- // ---- IME 通道 ----
override fun onCheckIsTextEditor(): Boolean = listener?.isReadyForInput() == true 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 normX(x: Float) = (x / width * 65535).toInt().coerceIn(0, 65535)
private fun normY(y: Float) = (y / height * 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) = private fun moved(fromX: Float, fromY: Float, toX: Float, toY: Float) =
hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx
@ -166,8 +259,15 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
lastX = event.x; lastY = event.y lastX = event.x; lastY = event.y
downTime = System.currentTimeMillis() downTime = System.currentTimeMillis()
mode = Mode.TAP mode = Mode.TAP
// 先把远端指针移到位,点击/拖拽都以此为基准 longPressFired = false
l.sendMouseMove(normX(event.x), normY(event.y)) 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("按下") onDebug?.invoke("按下")
} }
@ -176,6 +276,8 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
// 必须先抬起,否则远端会留下一个被按住未释放的左键。 // 必须先抬起,否则远端会留下一个被按住未释放的左键。
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false) if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.TWO_FINGER mode = Mode.TWO_FINGER
longPressTwoFinger = true
cancelLongPressTimer()
twoFingerMoved = false twoFingerMoved = false
wheelAccum = 0f wheelAccum = 0f
twoFingerStartY = midY(event) twoFingerStartY = midY(event)
@ -186,16 +288,28 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
MotionEvent.ACTION_MOVE -> when (mode) { MotionEvent.ACTION_MOVE -> when (mode) {
Mode.TAP -> if (moved(downX, downY, event.x, event.y)) { Mode.TAP -> if (moved(downX, downY, event.x, event.y)) {
// 超过 slop = 拖拽,长按不再可能(先取消计时,避免拖到一半弹菜单)
cancelLongPressTimer()
mode = Mode.DRAG mode = Mode.DRAG
l.sendMouseButton(BTN_LEFT, true) 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 lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
onDebug?.invoke("拖拽中") onDebug?.invoke("拖拽中")
} }
Mode.DRAG -> { 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 lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
} }
Mode.TWO_FINGER -> { Mode.TWO_FINGER -> {
@ -226,13 +340,22 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
cancelLongPressTimer()
when (mode) { when (mode) {
Mode.TAP -> { Mode.TAP -> {
// 轻点 = 左键单击(位置已在 DOWN 时发送) if (longPressFired) {
l.sendMouseButton(BTN_LEFT, true) // 长按已弹右键:本次 UP 不能再发左键单击,
l.sendMouseButton(BTN_LEFT, false) // 否则一次长按会「先右键再左键」,右键菜单立刻被左键点掉
performClick() onDebug?.invoke("长按结束")
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 -> { Mode.DRAG -> {
l.sendMouseButton(BTN_LEFT, false) l.sendMouseButton(BTN_LEFT, false)
@ -244,11 +367,14 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
Mode.IDLE -> {} Mode.IDLE -> {}
} }
mode = Mode.IDLE mode = Mode.IDLE
longPressFired = false
} }
MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_CANCEL -> {
cancelLongPressTimer()
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false) if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.IDLE mode = Mode.IDLE
longPressFired = false
onDebug?.invoke("手势取消") onDebug?.invoke("手势取消")
} }
} }
@ -259,10 +385,13 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
private fun maybeRightClick(l: Listener): Boolean { private fun maybeRightClick(l: Listener): Boolean {
if (twoFingerMoved) return false if (twoFingerMoved) return false
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) 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, true)
l.sendMouseButton(BTN_RIGHT, false) l.sendMouseButton(BTN_RIGHT, false)
performClick() performClick()
listener?.onRightClickInjected("双指")
return true return true
} }

View File

@ -36,6 +36,11 @@ object RustBridge {
external fun nativeStats(handle: Long): Long external fun nativeStats(handle: Long): Long
external fun nativeDesktopSize(handle: Long): Long external fun nativeDesktopSize(handle: Long): Long
external fun nativeSendMouseMove(handle: Long, x: Int, y: Int) 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 nativeSendMouseButton(handle: Long, button: Int, down: Int)
external fun nativeSendWheel(handle: Long, dy: Int) external fun nativeSendWheel(handle: Long, dy: Int)
external fun nativeSendKey(handle: Long, scancode: Int, extended: Int, down: Int) external fun nativeSendKey(handle: Long, scancode: Int, extended: Int, down: Int)