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:
parent
3773a0851c
commit
7c3b6b3e93
@ -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")
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user