Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6c3f15a2 | ||
|
|
aede7b6c3c | ||
|
|
7c3b6b3e93 | ||
|
|
3773a0851c | ||
|
|
2022bbcca0 | ||
|
|
78ac9d9972 | ||
|
|
39af42e1b3 |
No files matched your search
Generated
+1
@@ -1434,6 +1434,7 @@ dependencies = [
|
||||
"quinn",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -63,6 +63,16 @@ pub trait InputInjector: Send + Sync + 'static {
|
||||
units.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// 协议 v4 的相对鼠标移动(单位:被控端桌面物理像素)。
|
||||
///
|
||||
/// 与 [`InputInjector::text`] 同一条纪律:**默认实现不注入,但必须留下可见痕迹**。
|
||||
/// Linux/X11 后端本轮未实现相对移动(XTest 的相对语义要自己维护指针状态,
|
||||
/// 与 Windows 的 `GetCursorPos`+绝对注入不同构,另立循环)。用户用相对模式
|
||||
/// 操作 Linux 被控端时「指针纹丝不动」不能是静默的,日志必须说明是没实现。
|
||||
fn mouse_move_rel(&self, dx: i16, dy: i16) {
|
||||
eprintln!("[agent-core] 相对鼠标注入未实现(忽略 dx={dx} dy={dy})");
|
||||
}
|
||||
}
|
||||
|
||||
/// 把一条协议输入事件分发到注入后端。
|
||||
@@ -85,6 +95,9 @@ pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) {
|
||||
}
|
||||
inj.text(&units);
|
||||
}
|
||||
// v4:相对移动原样下发(含负值)。clamp 与像素换算由注入后端负责——
|
||||
// 只有它知道被控端桌面的真实像素尺寸(`agent-core` 是平台无关层)。
|
||||
InputEvent::MouseMoveRel { dx, dy } => inj.mouse_move_rel(dx, dy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +601,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// v4:相对移动必须被分发,**负值原样到达后端**(负值是相对语义的核心,
|
||||
/// 绝对坐标是无符号的,这条路径是新引入的有符号通道)。
|
||||
#[test]
|
||||
fn rel_move_reaches_backend_with_signed_deltas() {
|
||||
struct RelInjector {
|
||||
got: Mutex<Vec<(i16, i16)>>,
|
||||
}
|
||||
impl InputInjector for RelInjector {
|
||||
fn mouse_move(&self, _x: u16, _y: u16) {}
|
||||
fn mouse_button(&self, _b: MouseButton, _down: bool) {}
|
||||
fn wheel(&self, _dy: i32) {}
|
||||
fn key(&self, _s: u16, _e: bool, _d: bool) {}
|
||||
fn mouse_move_rel(&self, dx: i16, dy: i16) {
|
||||
self.got.lock().unwrap().push((dx, dy));
|
||||
}
|
||||
}
|
||||
let inj = RelInjector {
|
||||
got: Mutex::new(Vec::new()),
|
||||
};
|
||||
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: 12, dy: -34 });
|
||||
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: -1, dy: 0 });
|
||||
assert_eq!(
|
||||
*inj.got.lock().unwrap(),
|
||||
vec![(12, -34), (-1, 0)],
|
||||
"相对增量(含负值)必须原样到达注入后端"
|
||||
);
|
||||
}
|
||||
|
||||
/// 后端**没有**实现 mouse_move_rel 时不得 panic,且相对事件不得被误路由成
|
||||
/// 绝对移动(Linux/X11 后端的真实形态:默认实现 + 日志)。
|
||||
#[test]
|
||||
fn rel_move_default_backend_does_not_panic() {
|
||||
let inj = MockInjector::default();
|
||||
dispatch_input(&inj, InputEvent::MouseMoveRel { dx: 5, dy: 5 });
|
||||
// 默认实现只打日志,不应产生任何注入调用(尤其不能变成 move/绝对移动)
|
||||
assert!(
|
||||
inj.take().is_empty(),
|
||||
"默认实现不得把相对移动降级成绝对移动"
|
||||
);
|
||||
}
|
||||
|
||||
/// v3 关键回归:`Text` 必须被分发,且**后端没有实现 text() 时不能 panic**。
|
||||
/// 这是 Linux/X11 后端的真实形态——用户对着 Linux 被控端打字会走到这里。
|
||||
#[test]
|
||||
|
||||
@@ -24,4 +24,7 @@ windows = { workspace = true, features = [
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
# SetProcessDpiAwarenessContext:相对鼠标的 GetCursorPos 必须与采集同为物理像素,
|
||||
# 不能依赖注册表 AppCompat 覆盖(见 3b 开工实测 §4)。
|
||||
"Win32_UI_HiDpi",
|
||||
] }
|
||||
+60
-2
@@ -9,9 +9,10 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
MOUSE_EVENT_FLAGS,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
||||
SM_YVIRTUALSCREEN,
|
||||
GetCursorPos, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_CXSCREEN,
|
||||
SM_CYSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||
};
|
||||
use windows::Win32::Foundation::POINT;
|
||||
|
||||
// windows 0.58 未导出 LEFT 常量,按 WinUser.h 原值定义
|
||||
const MOUSEEVENTF_LEFTDOWN: u32 = 0x0002;
|
||||
@@ -36,6 +37,62 @@ fn send_mouse(flags: u32, dx: i32, dy: i32, data: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主显示器物理尺寸(像素)。相对移动的 clamp 上界必须与**采集空间**一致
|
||||
/// (capture 用 `EnumOutputs(0)`;AuthOk 下发的 width/height 也来自它)。
|
||||
fn output_size() -> (i32, i32) {
|
||||
unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_CXSCREEN),
|
||||
GetSystemMetrics(SM_CYSCREEN),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// v4 相对鼠标:把远端指针从当前位置移动 `(dx, dy)` 像素。
|
||||
///
|
||||
/// **为什么不用 `SendInput(MOUSEEVENTF_MOVE)`**(协议 v4 的语义最容易在这里跑偏):
|
||||
/// 实测(2026-10-31,见个人层 `docs/output/2026-10-31-3b-开工前实测.md`)该路径的
|
||||
/// `dx` 不是像素,而是「mickey」,会被两件事放大:
|
||||
/// 1. **指针加速**(「提高指针精确度」)。本机 `SPI_GETMOUSE=6/10/1` 下
|
||||
/// dx=20 实际走 41px、dx=100 走 309px,且倍率是非线性的;
|
||||
/// 2. **DPI 缩放**。关掉加速后倍率恒为 1.25 = 本机 125% 缩放。
|
||||
/// 两者都随用户系统设置与机器变化,无法标定,因此**不能**把协议里的像素增量直接透传。
|
||||
///
|
||||
/// 改为「读光标 → 加增量 → clamp → 绝对注入」:
|
||||
/// - 语义仍是**相对**(主控端只说移动多少,不说去哪里),远端指针始终权威;
|
||||
/// - 实测 1:1 精确(dx=±1/2/3/5/17/100 四次重复全部等于请求值);
|
||||
/// - 撞边后回滑立即响应,不积累「步数债」(clamp 丢掉的部分不回补)。
|
||||
///
|
||||
/// 已知限制(不隐藏):读光标与注入之间若用户同时移动真鼠标,会有一次增量被叠加。
|
||||
/// 本机联调场景可接受;彻底消除需要 `SendInput` 的 mickey 路径,而那条路不可标定。
|
||||
fn send_mouse_rel(dx: i16, dy: i16) {
|
||||
if dx == 0 && dy == 0 {
|
||||
return; // 零位移没有注入语义,也不该产生一次系统调用
|
||||
}
|
||||
let (w, h) = output_size();
|
||||
if w <= 1 || h <= 1 {
|
||||
eprintln!("[input] 相对移动失败:输出尺寸异常 {w}x{h}");
|
||||
return;
|
||||
}
|
||||
let mut cur = POINT::default();
|
||||
// GetCursorPos 失败时不能猜一个坐标继续注入——那会把指针扔到屏幕角落
|
||||
if let Err(e) = unsafe { GetCursorPos(&mut cur) } {
|
||||
eprintln!("[input] GetCursorPos 失败,相对移动被丢弃: {e}");
|
||||
return;
|
||||
}
|
||||
let tx = (cur.x + dx as i32).clamp(0, w - 1);
|
||||
let ty = (cur.y + dy as i32).clamp(0, h - 1);
|
||||
// 归一化到 0..=65535(绝对注入空间 = 主显示器,不带 VIRTUALDESK,见 MouseMove 分支)
|
||||
let nx = ((tx as i64 * 65535 + (w as i64 - 1) / 2) / (w as i64 - 1)) as i32;
|
||||
let ny = ((ty as i64 * 65535 + (h as i64 - 1) / 2) / (h as i64 - 1)) as i32;
|
||||
send_mouse(
|
||||
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
|
||||
nx,
|
||||
ny,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
fn send_key(scancode: u16, extended: bool, up: bool) {
|
||||
let mut flags = KEYEVENTF_SCANCODE;
|
||||
if extended {
|
||||
@@ -167,6 +224,7 @@ pub fn handle(ev: InputEvent) {
|
||||
}
|
||||
InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false),
|
||||
InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true),
|
||||
InputEvent::MouseMoveRel { dx, dy } => send_mouse_rel(dx, dy),
|
||||
InputEvent::Text { units } => {
|
||||
// 上限截断必须可见(与 agent-core::dispatch_input 同口径;Windows 端
|
||||
// 走自己的 handle(),不经过 agent-core,所以这里同源补齐——R-3 教训)。
|
||||
|
||||
@@ -32,8 +32,60 @@ fn parse_args() -> Result<(u16, String)> {
|
||||
Ok((port, code))
|
||||
}
|
||||
|
||||
/// 显式声明进程 DPI 感知(**必须在任何采集/注入调用之前**,越早越好)。
|
||||
///
|
||||
/// 为什么不能省(3b 开工实测,见个人层 `docs/output/2026-10-31-3b-开工前实测.md` §4):
|
||||
/// 相对鼠标走「`GetCursorPos` + 增量」,而 `GetCursorPos` 的坐标空间取决于进程的
|
||||
/// DPI 感知模式——UNAWARE 进程拿到的是**逻辑坐标**(本机 125% 缩放下是 1536x864),
|
||||
/// 而 DXGI 采集与 `AuthOk` 下发的是**物理像素**(1920x1080)。两者混用会让
|
||||
/// clamp 上界偏小、增量尺度偏小:指针到不了屏幕右/下边缘,且移动距离只有 80%。
|
||||
///
|
||||
/// 为什么不能依赖现状:本机 `agent.exe` 实测确实是 `PER_MONITOR_AWARE(2)`,但那是
|
||||
/// `HKCU\...\AppCompatFlags\Layers` 里一条**路径绑定**的 `HIGHDPIAWARE` 覆盖造成的
|
||||
/// ——同一个 exe 复制到 `%TEMP%` 启动就变成 `UNAWARE(0)`。换机器、换构建路径、
|
||||
/// CI 产物、用户机器上都不成立,所以必须由代码显式设置。
|
||||
///
|
||||
/// 失败时只告警不退出:DPI 感知不可用是老系统(< Win10 1703)的情况,
|
||||
/// 此时相对鼠标会退化(精度下降),但绝对注入与其余功能不受影响——
|
||||
/// 让整个被控端起不来是更差的选择。
|
||||
fn init_dpi_awareness() {
|
||||
use windows::Win32::UI::HiDpi::{
|
||||
GetThreadDpiAwarenessContext, SetProcessDpiAwarenessContext, GetAwarenessFromDpiAwarenessContext,
|
||||
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||
};
|
||||
match unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) } {
|
||||
Ok(()) => println!("[agent] DPI awareness: PER_MONITOR_AWARE_V2 (set by this process)"),
|
||||
Err(e) => {
|
||||
// 失败有两种完全不同的原因,**必须分开报告**,否则日志会误导排查方向:
|
||||
// a) 进程的感知模式已被别处设定(注册表 AppCompat 覆盖、清单、父进程)——
|
||||
// ERROR_ACCESS_DENIED(0x80070005),此时若已经是 per-monitor 就完全没问题;
|
||||
// b) 系统过旧不支持该 API——这才是真正的降级。
|
||||
// 本机实测是 (a):HKCU 的 AppCompatFlags\Layers 里有一条路径绑定的 HIGHDPIAWARE。
|
||||
let actual = unsafe {
|
||||
GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext())
|
||||
};
|
||||
// DPI_AWARENESS(WinUser.h):INVALID=-1、UNAWARE=0、SYSTEM_AWARE=1、
|
||||
// PER_MONITOR_AWARE=2。注意 PER_MONITOR 是 **2** 而不是 3。
|
||||
let is_per_monitor = actual.0 == 2;
|
||||
if is_per_monitor {
|
||||
println!(
|
||||
"[agent] DPI awareness: 已由外部设定为 PER_MONITOR_AWARE(无需本进程设置;{e})"
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"[agent] 警告:DPI 感知为 {}(非 per-monitor),且本进程设置失败({e})。\
|
||||
相对鼠标的位移比例会与被采集桌面不一致;绝对触摸不受影响。",
|
||||
actual.0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 第一件事:定下 DPI 感知,后续所有坐标运算都依赖它
|
||||
init_dpi_awareness();
|
||||
let (port, code) = parse_args()?;
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
|
||||
@@ -225,6 +225,21 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseMove(
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseRel(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
dx: jint,
|
||||
dy: jint,
|
||||
) {
|
||||
// 协议 v4 的字段是 i16:Kotlin 侧传 Int,越界一律 clamp 而不是静默回绕。
|
||||
// 越界值只可能来自主控端 bug 或恶意对端;clamp 让行为有界且可见(不 panic、不 UB)。
|
||||
let dx = dx.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
let dy = dy.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
with_session(handle, |s| s.send_mouse_rel(dx, dy));
|
||||
}
|
||||
|
||||
/// button: 0=左 1=右 其他=中;down: 1=按下 0=抬起
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseButton(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.verify.remote
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 3b/V-B1:相对鼠标的纯逻辑(协议 v4)。
|
||||
*
|
||||
* 这些断言对应验收判据,不是「跑通就算」:
|
||||
* - V-B1b「慢速小幅滑动不丢位移」→ [accumulatorKeepsSubPixelRemainders]
|
||||
* - V-B1c「撞边后回滑立即响应」→ 累积器只做取整、不做补偿([accumulatorDoesNotBankLostSteps])
|
||||
*/
|
||||
class RelPointerTest {
|
||||
|
||||
// ---- 换算:视图像素 → 桌面像素 ----
|
||||
|
||||
@Test
|
||||
fun sensitivity_one_maps_full_view_width_to_full_desktop_width() {
|
||||
// Q4=A 的语义定义:1.0 = 手指扫过视图全宽 = 指针走完桌面全宽
|
||||
val d = RelPointer.toDesktopDelta(
|
||||
viewDeltaPx = 1080f, // 视图全宽(1080x607 letterbox)
|
||||
viewExtentPx = 1080,
|
||||
remoteExtentPx = 1920, // 远端桌面宽
|
||||
sensitivity = 1.0f,
|
||||
)
|
||||
assertEquals(1920f, d, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sensitivity_scales_linearly() {
|
||||
val base = RelPointer.toDesktopDelta(100f, 1080, 1920, 1.0f)
|
||||
val twice = RelPointer.toDesktopDelta(100f, 1080, 1920, 2.0f)
|
||||
assertEquals(base * 2f, twice, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negative_delta_stays_negative() {
|
||||
// 向左滑必须得到负增量(绝对坐标是无符号的,相对通道是唯一有符号路径)
|
||||
val d = RelPointer.toDesktopDelta(-54f, 1080, 1920, 1.0f)
|
||||
assertTrue("向左滑应得到负增量,实际 $d", d < 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalid_extents_yield_zero_not_garbage() {
|
||||
// 视图未测量 / 远端尺寸未取到时必须不动,而不是按错误比例乱飞
|
||||
assertEquals(0f, RelPointer.toDesktopDelta(50f, 0, 1920, 1.0f), 0f)
|
||||
assertEquals(0f, RelPointer.toDesktopDelta(50f, 1080, 0, 1.0f), 0f)
|
||||
assertEquals(0f, RelPointer.toDesktopDelta(0f, 1080, 1920, 1.0f), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sensitivity_is_clamped_to_documented_range() {
|
||||
assertEquals(RelPointer.MIN_SENSITIVITY, RelPointer.clampSensitivity(0.01f), 0f)
|
||||
assertEquals(RelPointer.MAX_SENSITIVITY, RelPointer.clampSensitivity(99f), 0f)
|
||||
assertEquals(1.5f, RelPointer.clampSensitivity(1.5f), 0f)
|
||||
// 边界常量本身必须与方案 Q4=A 一致
|
||||
assertEquals(0.25f, RelPointer.MIN_SENSITIVITY, 0f)
|
||||
assertEquals(3.0f, RelPointer.MAX_SENSITIVITY, 0f)
|
||||
assertEquals(1.0f, RelPointer.DEFAULT_SENSITIVITY, 0f)
|
||||
}
|
||||
|
||||
// ---- 累积取整:V-B1b ----
|
||||
|
||||
@Test
|
||||
fun accumulatorKeepsSubPixelRemainders() {
|
||||
// 每次只移动 0.3 桌面像素:逐次 toInt() 会全被吞掉(位移静默丢失)。
|
||||
// 累积器必须在攒够 1 像素时发出,且总量守恒。
|
||||
val acc = RelAccumulator()
|
||||
var total = 0
|
||||
repeat(10) {
|
||||
acc.take(0.3f, 0f)?.let { total += it.first }
|
||||
}
|
||||
// 10 * 0.3 = 3.0 → 必须发出 3 像素,而不是 0
|
||||
assertEquals("慢速滑动不得丢位移", 3, total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accumulatorDoesNotEmitBelowHalfPixel() {
|
||||
// 小于 0.5 的余量四舍五入后是 0 → 不发(否则会产生大量零位移消息)。
|
||||
// 注意阈值是 0.5 而不是 1.0:累积器用 roundToInt 而非 toInt,
|
||||
// 0.8 会先发出 1 并把 -0.2 留作余数(自我修正,长期总量守恒)。
|
||||
val acc = RelAccumulator()
|
||||
assertNull("0.4 四舍五入为 0,不得发送", acc.take(0.4f, 0.4f))
|
||||
// 再攒 0.4 → 合计 0.8,四舍五入为 1,此时必须发出
|
||||
val d = acc.take(0.4f, 0.4f)
|
||||
assertEquals(1, d!!.first)
|
||||
assertEquals(1, d.second)
|
||||
// 余数 -0.2:紧接着的 0.2 会被抵消成 0,不再发送(证明余数被正确保留)
|
||||
assertNull(acc.take(0.2f, 0.2f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accumulatorRoundsNegativeValuesAwayFromZero() {
|
||||
// -0.7 应取 -1(向零截断会得到 0,反向慢滑就丢了)
|
||||
val acc = RelAccumulator()
|
||||
val d = acc.take(-0.7f, 0f)
|
||||
assertEquals(-1, d!!.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accumulatorAxesAreIndependent() {
|
||||
// 斜向慢滑:X 攒够时 Y 还没攒够,不能因为 Y 不够就把 X 也拖住
|
||||
val acc = RelAccumulator()
|
||||
val d = acc.take(1.0f, 0.2f)
|
||||
assertEquals(1, d!!.first)
|
||||
assertEquals(0, d.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accumulatorResetDropsRemainder() {
|
||||
// 手势/会话结束时清空:上一段的余数不该漏进下一段
|
||||
val acc = RelAccumulator()
|
||||
acc.take(0.6f, 0.6f)
|
||||
acc.reset()
|
||||
assertNull(acc.take(0.3f, 0.3f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accumulatorDoesNotBankLostSteps() {
|
||||
// V-B1c:撞边时被 clamp 丢掉的位移**不回补**。累积器只负责取整,
|
||||
// 不负责补偿——否则撞边后回滑会先"空转"一段,违反「立即响应」。
|
||||
val acc = RelAccumulator()
|
||||
acc.take(1.0f, 0f) // 发出 +1
|
||||
val back = acc.take(-1.0f, 0f)
|
||||
assertEquals("回滑必须立即发出,不得因补偿而空转", -1, back!!.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accumulatorClampsToProtocolRange() {
|
||||
// 协议字段是 i16;越界必须被 clamp 而不是回绕(回绕会让指针跳到屏幕另一侧)
|
||||
val acc = RelAccumulator()
|
||||
val d = acc.take(100_000f, -100_000f)
|
||||
assertEquals(32767, d!!.first)
|
||||
assertEquals(-32768, d.second)
|
||||
}
|
||||
|
||||
// ---- 长按右键:V-B3 ----
|
||||
|
||||
@Test
|
||||
fun longPress_fires_at_threshold_when_still() {
|
||||
assertTrue(LongPressPolicy.shouldFire(500L, 0f, 30f, false))
|
||||
assertTrue(LongPressPolicy.shouldFire(900L, 2f, 30f, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longPress_does_not_fire_too_early() {
|
||||
assertFalse(LongPressPolicy.shouldFire(499L, 0f, 30f, false))
|
||||
assertFalse(LongPressPolicy.shouldFire(0L, 0f, 30f, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longPress_does_not_fire_after_moving() {
|
||||
// V-B3「拖动窗口时不弹菜单」:超过 slop 一律不算长按
|
||||
assertFalse(LongPressPolicy.shouldFire(800L, 31f, 30f, false))
|
||||
assertFalse(LongPressPolicy.shouldFire(800L, 200f, 30f, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longPress_does_not_fire_with_two_fingers() {
|
||||
// 双指有自己的右键路径,长按不能叠加(否则一次双指轻点会出两个右键)
|
||||
assertFalse(LongPressPolicy.shouldFire(900L, 0f, 30f, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun long_press_threshold_is_pinned_to_decision() {
|
||||
// Q5=A:500ms,改动必须显式经过这条断言
|
||||
assertEquals(500L, LongPressPolicy.LONG_PRESS_MS)
|
||||
}
|
||||
}
|
||||
+73
-3
@@ -19,7 +19,10 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
/// - v3:新增 `InputEvent::Text`(文本注入通道,UTF-16 code unit)。
|
||||
/// `InputEvent` 因携带 `Vec<u16>` 不再是 `Copy`;三端必须同步升级,
|
||||
/// 旧端由 `Auth` 握手显式拒绝。
|
||||
pub const PROTO_VERSION: u16 = 3;
|
||||
/// - v4:新增 `InputEvent::MouseMoveRel`(相对鼠标,单位=被控端桌面物理像素)。
|
||||
/// bincode 按位置编码枚举判别序号,新增变体会改变其后变体的编号,
|
||||
/// 故同样必须三端同步;旧端由 `Auth` 握手显式拒绝。
|
||||
pub const PROTO_VERSION: u16 = 4;
|
||||
|
||||
/// 控制通道消息(双向)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -78,6 +81,25 @@ pub enum InputEvent {
|
||||
///
|
||||
/// 日志纪律:中间层与注入端只允许打印 `units.len()`,**不得**打印用户输入内容。
|
||||
Text { units: Vec<u16> },
|
||||
/// v4 新增:**相对**鼠标位移,单位是**被控端桌面的物理像素**(不是归一化值)。
|
||||
///
|
||||
/// 语义:把远端指针从**当前位置**移动 `(dx, dy)`,越界部分由被控端 clamp 到桌面内。
|
||||
/// 主控端只说「移动多少」,不说「去哪里」——远端指针位置始终是唯一权威,
|
||||
/// 本机真鼠标同时移动也不会造成跳变。
|
||||
///
|
||||
/// 为什么单位是像素而不是归一化增量:`AuthOk` 已下发桌面 `width`/`height`,
|
||||
/// 主控端换算一次即可;被控端拿到像素可直接与 `GetCursorPos` 同尺度相加,
|
||||
/// 少一次取整(每多一次取整就多一个累积误差源)。
|
||||
///
|
||||
/// 为什么是 `i16`:单次触摸事件的位移远小于 32767;用定长整数让线格式保持紧凑。
|
||||
///
|
||||
/// **与 [`InputEvent::MouseMove`] 互斥**:绝对模式发 `MouseMove`,相对模式发本变体,
|
||||
/// 由主控端的模式开关决定,同一会话内不应混用(混用不会出错,但手感会跳)。
|
||||
///
|
||||
/// 注入实现注意:Windows 上**不能**直接 `SendInput(MOUSEEVENTF_MOVE)` 透传
|
||||
/// ——实测该路径被「指针加速」与「DPI 缩放」双重放大(本机 dx=100 实际走了 309px,
|
||||
/// 且倍率随系统设置变化)。正确做法见 `agent/src/input.rs::send_mouse_rel`。
|
||||
MouseMoveRel { dx: i16, dy: i16 },
|
||||
}
|
||||
|
||||
/// [`InputEvent::Text`] 单次携带的 code unit 上限。
|
||||
@@ -313,10 +335,58 @@ mod tests {
|
||||
|
||||
// ---- v3 文本注入通道 ----
|
||||
|
||||
/// 版本守卫:v3 是本轮契约变更的登记点,改动必须显式经过这里。
|
||||
/// 版本守卫:v3 是文本通道的登记点。v4 之后此断言随版本推进,
|
||||
/// 保留它的作用是「任何一次版本变更都必须显式经过这里」。
|
||||
#[test]
|
||||
fn proto_version_is_three_after_text_event() {
|
||||
assert_eq!(PROTO_VERSION, 3, "新增 InputEvent::Text 后必须递增到 3");
|
||||
assert!(
|
||||
PROTO_VERSION >= 3,
|
||||
"Text 通道自 v3 起存在,版本不得回退(当前 {PROTO_VERSION})"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- v4 相对鼠标 ----
|
||||
|
||||
/// 版本守卫:v4 是相对鼠标的登记点。
|
||||
#[test]
|
||||
fn proto_version_is_four_after_rel_move() {
|
||||
assert_eq!(
|
||||
PROTO_VERSION, 4,
|
||||
"新增 InputEvent::MouseMoveRel 后必须递增到 4"
|
||||
);
|
||||
}
|
||||
|
||||
/// 相对位移必须原样往返,**包含负值**(向左/向上移动)。
|
||||
/// 负值是本变体的核心语义:绝对坐标是无符号的,相对增量必须有符号。
|
||||
#[test]
|
||||
fn mouse_move_rel_roundtrips_signed_deltas() {
|
||||
for (dx, dy) in [(0i16, 0i16), (1, -1), (-32768, 32767), (100, -250)] {
|
||||
let m = ControlMsg::Input(InputEvent::MouseMoveRel { dx, dy });
|
||||
let bytes = encode_control(&m);
|
||||
let back: ControlMsg = bincode::deserialize(&bytes[4..]).unwrap();
|
||||
match back {
|
||||
ControlMsg::Input(InputEvent::MouseMoveRel { dx: gx, dy: gy }) => {
|
||||
assert_eq!((gx, gy), (dx, dy), "相对增量往返失真");
|
||||
}
|
||||
other => panic!("期望 Input::MouseMoveRel,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判别序号守卫:`MouseMoveRel` 必须与既有变体**互不混淆**。
|
||||
/// bincode 按位置编码枚举判别符,若新变体插在中间会让旧端静默错解——
|
||||
/// 这条测试锁住「新增变体后各变体仍各自往返」。
|
||||
#[test]
|
||||
fn rel_move_does_not_alias_other_input_events() {
|
||||
let rel = ControlMsg::Input(InputEvent::MouseMoveRel { dx: 7, dy: -9 });
|
||||
let abs = ControlMsg::Input(InputEvent::MouseMove { x: 7, y: 9 });
|
||||
let rb = encode_control(&rel);
|
||||
let ab = encode_control(&abs);
|
||||
assert_ne!(rb, ab, "相对与绝对移动的线格式不得相同");
|
||||
match bincode::deserialize::<ControlMsg>(&rb[4..]).unwrap() {
|
||||
ControlMsg::Input(InputEvent::MouseMoveRel { .. }) => {}
|
||||
other => panic!("相对移动被错解为 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -144,12 +144,27 @@ while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 900; $ui = Get-Ui;
|
||||
if ($ui -notmatch "RTT=") { throw "phone did not connect" }
|
||||
|
||||
Write-Host "=== 4. show IME (tap keyboard button) ==="
|
||||
$btns = [regex]::Matches($ui, '<node[^>]*class="android\.widget\.Button"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||
if ($btns.Count -lt 1) { throw "keyboard button not found" }
|
||||
$b0 = $btns[0]
|
||||
$kx = [int](([int]$b0.Groups[1].Value + [int]$b0.Groups[3].Value) / 2)
|
||||
$ky = [int](([int]$b0.Groups[2].Value + [int]$b0.Groups[4].Value) / 2)
|
||||
[void](Adb-Shell @("input","tap","$kx","$ky"))
|
||||
# Select the keyboard button BY ITS LABEL, not by position.
|
||||
# 3b added a sensitivity row (- / +) above the button bar, so "the first Button"
|
||||
# is now the minus sign - the old positional assumption silently tapped the wrong
|
||||
# control and the proof reported "IME not shown" (a harness defect, not a product
|
||||
# regression; measured 2026-10-31). Label matching is order-independent.
|
||||
$F_KEYBOARD = -join ([char]0x952E,[char]0x76D8) # "keyboard"
|
||||
$kb = $null
|
||||
foreach ($n in [regex]::Matches($ui,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||
if ($t -eq $F_KEYBOARD) {
|
||||
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||
if ($b.Success) {
|
||||
$kb = @(
|
||||
[int](([int]$b.Groups[1].Value + [int]$b.Groups[3].Value) / 2),
|
||||
[int](([int]$b.Groups[2].Value + [int]$b.Groups[4].Value) / 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $kb) { throw "keyboard button not found by label" }
|
||||
[void](Adb-Shell @("input","tap","$($kb[0])","$($kb[1])"))
|
||||
Start-Sleep -Seconds 3
|
||||
$ime = (Adb-Shell @("dumpsys","input_method")).out
|
||||
if ($ime -notmatch "mInputShown=true") { throw "IME not shown" }
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
# 3b phone-side proof: relative mouse (protocol v4) + long-press right click + R7 regression.
|
||||
#
|
||||
# ASCII-only source (PS 5.1 decodes BOM-less .ps1 as GBK; see personal lessons).
|
||||
# Expected Chinese UI strings are matched as CODE-POINT fragments (R-8: assert the
|
||||
# actual value, and print what the parser really read so an empty result cannot pass).
|
||||
#
|
||||
# Judgements:
|
||||
# V-B1a relative mode moves the PC pointer by the expected amount (direction+sign)
|
||||
# V-B2 absolute mode still maps finger -> desktop 1:1 (R7 re-run, <=8px error)
|
||||
# V-B3 long press (>=500ms, still) opens a context menu; a drag does NOT
|
||||
# V-B7 R7 + Chinese IME + full chain regressions stay green
|
||||
#
|
||||
# Prereqs: vivo device online via adb, 3b APK installed, v4 agent running.
|
||||
# powershell -ExecutionPolicy Bypass -File tools\rel_mouse_phone_proof.ps1 -Host_ 192.168.1.110
|
||||
param(
|
||||
[string]$Host_ = "192.168.1.110",
|
||||
[int]$Port = 47800,
|
||||
[string]$Code = "t",
|
||||
[string]$AgentExe = ""
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repo = (Resolve-Path "$PSScriptRoot\..").Path
|
||||
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
|
||||
$ev = Join-Path (Split-Path $repo -Parent) "evidence\3b"
|
||||
New-Item -ItemType Directory -Force -Path $ev | Out-Null
|
||||
|
||||
# ---- code-point fragments (grep-verified present in the Kotlin sources) ----
|
||||
$F_CONNECT = -join ([char]0x8FDE,[char]0x63A5) # "connect"
|
||||
$F_RECONNECT = -join ([char]0x91CD,[char]0x65B0,[char]0x8FDE,[char]0x63A5) # "reconnect"
|
||||
$F_RELATIVE = -join ([char]0x76F8,[char]0x5BF9) # "relative"
|
||||
$F_ABSOLUTE = -join ([char]0x7EDD,[char]0x5BF9) # "absolute"
|
||||
$F_MODE_TAG = -join ([char]0x6A21,[char]0x5F0F) # "mode"
|
||||
$F_LONGPRESS = -join ([char]0x957F,[char]0x6309,[char]0x53F3,[char]0x952E) # "long-press right click"
|
||||
$F_RCLICK_TAG = -join ([char]0x53F3,[char]0x952E,[char]0x3D) # "right-click="
|
||||
$F_CLICK = -join ([char]0x5355,[char]0x51FB) # "single click"
|
||||
|
||||
# Read the persistent right-click counter from the HUD ("右键=N(source)").
|
||||
# Returns -1 when absent. A counter is used instead of the one-line debug text
|
||||
# because ACTION_UP immediately overwrites the debug line after a long press
|
||||
# (measured 2026-10-31: screen showed "长按结束", the firing evidence was gone).
|
||||
function RightClickCount {
|
||||
$m = [regex]::Match((Get-UiText), 'text="([^"]*RTT=[^"]*)"')
|
||||
if (-not $m.Success) { return -1 }
|
||||
$hud = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value)
|
||||
$c = [regex]::Match($hud, "$F_RCLICK_TAG(\d+)")
|
||||
if ($c.Success) { return [int]$c.Groups[1].Value }
|
||||
return 0
|
||||
}
|
||||
|
||||
function Adb {
|
||||
param([Parameter(Mandatory)][string[]]$AdbArgs)
|
||||
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
||||
try { $out = & $adb @AdbArgs 2>$null; @{ out = ($out -join "`n"); code = $LASTEXITCODE } }
|
||||
finally { $ErrorActionPreference = $old }
|
||||
}
|
||||
function AdbShell {
|
||||
param([string[]]$Cmd)
|
||||
(Adb -AdbArgs (@("shell") + $Cmd)).out
|
||||
}
|
||||
function Get-UiText {
|
||||
$d = AdbShell @("uiautomator","dump","/sdcard/rm.xml")
|
||||
$r = Adb @("pull","/sdcard/rm.xml","$env:TEMP\rm.xml")
|
||||
if ($r.code -ne 0 -or -not (Test-Path "$env:TEMP\rm.xml")) {
|
||||
Write-Host " [ui] dump=[$($d -replace "`n",' ')] pull_rc=$($r.code) -> empty"
|
||||
return ""
|
||||
}
|
||||
$txt = [System.IO.File]::ReadAllText("$env:TEMP\rm.xml", [System.Text.Encoding]::UTF8)
|
||||
if ($txt.Length -lt 200) {
|
||||
Write-Host " [ui] short xml ($($txt.Length) chars): $($txt.Substring(0,[Math]::Min(120,$txt.Length)))"
|
||||
}
|
||||
return $txt
|
||||
}
|
||||
function VisibleTexts([string]$xml) {
|
||||
[regex]::Matches($xml,'text="([^"]+)"') | ForEach-Object { [System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value) }
|
||||
}
|
||||
function FormFields([string]$xml) {
|
||||
[regex]::Matches($xml,'<node[^>]*class="android\.widget\.EditText"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||
}
|
||||
function Center($m) {
|
||||
$a=[int]$m.Groups[1].Value; $b=[int]$m.Groups[2].Value
|
||||
$c=[int]$m.Groups[3].Value; $d=[int]$m.Groups[4].Value
|
||||
@([int](($a+$c)/2), [int](($b+$d)/2))
|
||||
}
|
||||
function ButtonCenter([string]$xml, [string]$want) {
|
||||
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||
if ($t -eq $want) {
|
||||
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||
if ($b.Success) {
|
||||
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function ButtonStartingWith([string]$xml, [string]$prefix) {
|
||||
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||
if ($t.StartsWith($prefix)) {
|
||||
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||
if ($b.Success) {
|
||||
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||
return @([int](($x1+$x2)/2), [int](($y1+$y2)/2))
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Toggle the pointer mode and PROVE it flipped by re-reading the button label.
|
||||
# The bottom bar sits at y=2256..2324, right against the system navigation bar;
|
||||
# a single tap there is not reliable (measured 2026-10-31: the tap landed but the
|
||||
# mode did not change). So: tap, wait, re-read, retry; and judge on the BUTTON
|
||||
# label as well as the HUD (two independent observations of the same state).
|
||||
function Set-Mode([string]$want) {
|
||||
# $want is "relative" or "absolute"
|
||||
$wantTag = if ($want -eq "relative") { $F_RELATIVE } else { $F_ABSOLUTE }
|
||||
for ($try=0; $try -lt 4; $try++) {
|
||||
$xml = Get-UiText
|
||||
$cur = $null
|
||||
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.widget\.Button"[^>]*>')) {
|
||||
$t = [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||
if ($t.Contains($F_MODE_TAG)) {
|
||||
$b = [regex]::Match($n.Value,'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
|
||||
if ($b.Success) {
|
||||
$x1=[int]$b.Groups[1].Value; $y1=[int]$b.Groups[2].Value
|
||||
$x2=[int]$b.Groups[3].Value; $y2=[int]$b.Groups[4].Value
|
||||
$cur = @{ label=$t; cx=[int](($x1+$x2)/2); cy=[int](($y1+$y2)/2) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $cur) { throw "mode button not found (no Button whose text contains '$F_MODE_TAG')" }
|
||||
Write-Host " mode button label now: [$($cur.label)] (try $try)"
|
||||
if ($cur.label.Contains($wantTag)) { return $true }
|
||||
[void](AdbShell @("input","tap","$($cur.cx)","$($cur.cy)"))
|
||||
Start-Sleep -Milliseconds 700
|
||||
}
|
||||
$final = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||
throw "mode did not switch to $want after 4 taps; screen: [$final]"
|
||||
}
|
||||
# The video SurfaceView bounds - touch coordinates must land inside it.
|
||||
#
|
||||
# uiautomator does NOT expose SurfaceView as "android.view.SurfaceView"; a bare
|
||||
# SurfaceView with no accessibility node collapses to a plain "android.view.View"
|
||||
# (measured 2026-10-31 on the live device: the only candidate is 1080x607, which
|
||||
# is exactly the 16:9 letterbox of the 1920x1080 desktop inside a 1080-wide stage).
|
||||
# So identify it structurally: the widest View whose aspect ratio matches 16:9.
|
||||
# Assert the ratio instead of assuming it - a silent wrong node would make every
|
||||
# downstream coordinate assertion meaningless.
|
||||
function SurfaceBounds([string]$xml) {
|
||||
$best = $null; $bestErr = [double]::MaxValue
|
||||
foreach ($n in [regex]::Matches($xml,'<node[^>]*class="android\.view\.View"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')) {
|
||||
$x1=[int]$n.Groups[1].Value; $y1=[int]$n.Groups[2].Value
|
||||
$x2=[int]$n.Groups[3].Value; $y2=[int]$n.Groups[4].Value
|
||||
$w = $x2-$x1; $h = $y2-$y1
|
||||
if ($w -lt 300 -or $h -lt 100) { continue }
|
||||
$ratio = $w / [double]$h
|
||||
$err = [Math]::Abs($ratio - (16.0/9.0))
|
||||
if ($err -lt $bestErr) { $bestErr = $err; $best = @($x1,$y1,$x2,$y2) }
|
||||
}
|
||||
if (-not $best) { return $null }
|
||||
# sanity: the chosen node must really be ~16:9, else we grabbed something else
|
||||
$w = $best[2]-$best[0]; $h = $best[3]-$best[1]
|
||||
if ($bestErr -gt 0.25) {
|
||||
throw "no 16:9 view found (best candidate ${w}x${h}, ratio $([Math]::Round($w/[double]$h,3)))"
|
||||
}
|
||||
Write-Host " video view ${w}x${h} (ratio $([Math]::Round($w/[double]$h,3)), 16:9 error $([Math]::Round($bestErr,4)))"
|
||||
return $best
|
||||
}
|
||||
|
||||
function TypeAscii([string]$s) {
|
||||
foreach ($ch in $s.ToUpper().ToCharArray()) {
|
||||
$k = if ($ch -ge '0' -and $ch -le '9') { 7 + [int][string]$ch }
|
||||
elseif ($ch -ge 'A' -and $ch -le 'Z') { 29 + ([int]$ch - [int][char]'A') }
|
||||
elseif ($ch -eq '.') { 56 }
|
||||
else { throw "unsupported char: $ch" }
|
||||
[void](AdbShell @("input","keyevent","$k"))
|
||||
Start-Sleep -Milliseconds 70
|
||||
}
|
||||
}
|
||||
function FocusedIndex([string]$xml) {
|
||||
$i = -1
|
||||
foreach ($n in [regex]::Matches($xml,'<node[^>]*>')) {
|
||||
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||
$i++
|
||||
if ($n.Value -match 'focused="true"') { return $i }
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
function Focus-Field($c, [int]$wantIdx) {
|
||||
for ($try=0; $try -lt 3; $try++) {
|
||||
[void](AdbShell @("input","tap","$($c[0])","$($c[1])"))
|
||||
$deadline = (Get-Date).AddSeconds(1.5)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
if ((FocusedIndex (Get-UiText)) -eq $wantIdx) { return }
|
||||
}
|
||||
}
|
||||
throw "tap did not focus field $wantIdx after 3 tries"
|
||||
}
|
||||
function Clear-Field($c, [int]$idx) {
|
||||
Focus-Field $c $idx
|
||||
for ($i=0; $i -lt 40; $i++) { [void](AdbShell @("input","keyevent","67")) }
|
||||
Start-Sleep -Milliseconds 150
|
||||
}
|
||||
function Restart-AppForm {
|
||||
[void](AdbShell @("input","keyevent","224"))
|
||||
Start-Sleep -Milliseconds 300
|
||||
[void](AdbShell @("wm","dismiss-keyguard"))
|
||||
Start-Sleep -Milliseconds 500
|
||||
[void](AdbShell @("am","force-stop","com.verify.remote"))
|
||||
Start-Sleep -Milliseconds 600
|
||||
[void](AdbShell @("am","start","-n","com.verify.remote/.MainActivity"))
|
||||
$deadline = (Get-Date).AddSeconds(8)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$x = Get-UiText
|
||||
if ((FormFields $x).Count -ge 3) { return $x }
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
$diag = (VisibleTexts (Get-UiText)) -join " || "
|
||||
throw "connect form did not appear. screen: [$diag]"
|
||||
}
|
||||
function FillAndConnect([string]$xml, [string]$host_, [string]$port_, [string]$code_) {
|
||||
$f = FormFields $xml
|
||||
if ($f.Count -lt 3) { throw "form has $($f.Count) fields" }
|
||||
Clear-Field (Center $f[0]) 0; TypeAscii $host_
|
||||
Clear-Field (Center $f[1]) 1; TypeAscii $port_
|
||||
Clear-Field (Center $f[2]) 2; TypeAscii $code_
|
||||
$expect = @($host_, $port_, $code_)
|
||||
for ($retry=0; $retry -le 2; $retry++) {
|
||||
$x2 = Get-UiText
|
||||
$vals = @()
|
||||
foreach ($n in [regex]::Matches($x2,'<node[^>]*>')) {
|
||||
if ($n.Value -match 'class="android\.widget\.EditText"') {
|
||||
$vals += [System.Net.WebUtility]::HtmlDecode([regex]::Match($n.Value,'text="([^"]*)"').Groups[1].Value)
|
||||
}
|
||||
}
|
||||
Write-Host " form now: $($vals -join ' | ') (try $retry)"
|
||||
if ($vals.Count -lt 3) { throw "expected 3 fields after typing, got $($vals.Count)" }
|
||||
$bad = -1
|
||||
for ($i=0; $i -lt 3; $i++) { if ($vals[$i] -ne $expect[$i]) { $bad = $i; break } }
|
||||
if ($bad -ge 0) {
|
||||
$f = FormFields $x2
|
||||
Clear-Field (Center $f[$bad]) $bad
|
||||
TypeAscii $expect[$bad]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if ($vals[0] -ne $host_) { throw "form input FAIL: host is [$($vals[0])], expected [$host_]" }
|
||||
if ($vals[1] -ne $port_) { throw "form input FAIL: port is [$($vals[1])], expected [$port_]" }
|
||||
if ($vals[2] -ne $code_) { throw "form input FAIL: code is [$($vals[2])], expected [$code_]" }
|
||||
$btn = $null
|
||||
foreach ($want in @($F_CONNECT, $F_RECONNECT)) { $btn = ButtonCenter $x2 $want; if ($btn) { break } }
|
||||
if (-not $btn) { throw "no primary connect/reconnect button found" }
|
||||
[void](AdbShell @("input","tap","$($btn[0])","$($btn[1])"))
|
||||
}
|
||||
function Wait-Live([int]$seconds, [ref]$hud) {
|
||||
$deadline = (Get-Date).AddSeconds($seconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$m = [regex]::Match((Get-UiText),'text="([^"]*RTT=[^"]*)"')
|
||||
if ($m.Success) { $hud.Value = [System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value); return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# ---- PC-side cursor readback (the only honest judge of "pointer moved N px") ----
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class PC {
|
||||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||||
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr ctx);
|
||||
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);
|
||||
public static void Dpi() { SetProcessDpiAwarenessContext(new IntPtr(-4)); }
|
||||
public static int[] Get() { POINT p; GetCursorPos(out p); return new int[]{p.X,p.Y}; }
|
||||
public static void Set(int x,int y) { SetCursorPos(x,y); }
|
||||
public static int W() { return GetSystemMetrics(0); }
|
||||
public static int H() { return GetSystemMetrics(1); }
|
||||
}
|
||||
"@
|
||||
[void][PC]::Dpi()
|
||||
$SW = [PC]::W(); $SH = [PC]::H()
|
||||
Write-Host " PC screen ${SW}x${SH} (physical, DPI-aware)"
|
||||
|
||||
Write-Host "=== 0. stale-artifact guard (R-7) + IME disable + keep-awake ==="
|
||||
if (-not $AgentExe) { $AgentExe = Join-Path $repo "target\release\agent.exe" }
|
||||
$ag = @(Get-Process agent -ErrorAction SilentlyContinue)
|
||||
if ($ag.Count -gt 0 -and (Test-Path $AgentExe) -and ((Get-Item $AgentExe).LastWriteTime -gt $ag[0].StartTime)) {
|
||||
Write-Host " running agent predates current build; restarting"
|
||||
Stop-Process -Name agent -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Milliseconds 800; $ag = @()
|
||||
}
|
||||
if ($ag.Count -eq 0) {
|
||||
if (-not (Test-Path $AgentExe)) { throw "missing $AgentExe" }
|
||||
Start-Process $AgentExe -ArgumentList "--port","$Port","--code",$Code -WindowStyle Hidden | Out-Null
|
||||
Start-Sleep -Seconds 1; Write-Host " agent started (v4)"
|
||||
} else { Write-Host " agent running and current" }
|
||||
|
||||
$origIme = ((AdbShell @("settings","get","secure","default_input_method")) -join "").Trim()
|
||||
Write-Host " original IME: [$origIme] (recorded, restored in finally)"
|
||||
if ($origIme -and $origIme -ne "null") { [void](AdbShell @("ime","disable",$origIme)) }
|
||||
[void](AdbShell @("svc","power","stayon","true"))
|
||||
|
||||
$origCursor = [PC]::Get()
|
||||
Write-Host " PC cursor origin ($($origCursor[0]),$($origCursor[1])) (restored in finally)"
|
||||
|
||||
try {
|
||||
# =================================================================
|
||||
Write-Host "=== 1. connect (protocol v4) and confirm HUD shows v4 ==="
|
||||
$x = Restart-AppForm
|
||||
[void](FillAndConnect $x $Host_ "$Port" $Code)
|
||||
$hud = ""
|
||||
if (-not (Wait-Live 14 ([ref]$hud))) { throw "never reached a live session" }
|
||||
Write-Host " HUD: $hud"
|
||||
if (-not $hud.Contains("v4")) { throw "V-B4 FAIL: HUD does not report protocol v4; got [$hud]" }
|
||||
Write-Host " PASS: live session on protocol v4"
|
||||
|
||||
$xml = Get-UiText
|
||||
$sb = SurfaceBounds $xml
|
||||
if (-not $sb) { throw "cannot locate SurfaceView bounds" }
|
||||
$vx1=$sb[0]; $vy1=$sb[1]; $vx2=$sb[2]; $vy2=$sb[3]
|
||||
$vcx=[int](($vx1+$vx2)/2); $vcy=[int](($vy1+$vy2)/2)
|
||||
Write-Host " surface bounds = [$vx1,$vy1][$vx2,$vy2] center=($vcx,$vcy)"
|
||||
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b1-live.png" | Out-Null
|
||||
|
||||
# =================================================================
|
||||
Write-Host "=== 2. V-B1a: relative mode moves PC pointer (swipe right) ==="
|
||||
# Park the PC cursor at a known point so the delta is unambiguous.
|
||||
# Let the link drain first (same discipline as 2c): residual events from an
|
||||
# earlier gesture would otherwise be attributed to this one.
|
||||
Start-Sleep -Milliseconds 1000
|
||||
[PC]::Set([int]($SW/2), [int]($SH/2))
|
||||
Start-Sleep -Milliseconds 500
|
||||
$before = [PC]::Get()
|
||||
# A horizontal swipe of ~40% of the view width. With sensitivity 1.0 and
|
||||
# view width mapping to desktop width, expected delta = 0.4 * SW.
|
||||
$swipeFromX = [int]($vx1 + ($vx2-$vx1)*0.30)
|
||||
$swipeToX = [int]($vx1 + ($vx2-$vx1)*0.70)
|
||||
[void](AdbShell @("input","swipe","$swipeFromX","$vcy","$swipeToX","$vcy","400"))
|
||||
Start-Sleep -Milliseconds 500
|
||||
$after = [PC]::Get()
|
||||
$moved = $after[0] - $before[0]
|
||||
Write-Host " swipe dx_device=$($swipeToX-$swipeFromX)px -> PC pointer dx=$moved px (before x=$($before[0]), after x=$($after[0]))"
|
||||
if ($moved -le 0) { throw "V-B1a FAIL: rightward swipe did not move the pointer right (dx=$moved)" }
|
||||
# Direction check is the hard judgement; exact magnitude depends on how many
|
||||
# ACTION_MOVE samples the ROM delivers for an `input swipe` (not equal to the
|
||||
# finger travel), so magnitude is reported, not asserted.
|
||||
Write-Host " PASS: relative mode moves the pointer in the finger's direction"
|
||||
|
||||
Write-Host "=== 2b. V-B1a negative: swipe left moves pointer left ==="
|
||||
Start-Sleep -Milliseconds 1000
|
||||
[PC]::Set([int]($SW/2), [int]($SH/2))
|
||||
Start-Sleep -Milliseconds 500
|
||||
$before2 = [PC]::Get()
|
||||
[void](AdbShell @("input","swipe","$swipeToX","$vcy","$swipeFromX","$vcy","400"))
|
||||
Start-Sleep -Milliseconds 500
|
||||
$after2 = [PC]::Get()
|
||||
$moved2 = $after2[0] - $before2[0]
|
||||
Write-Host " PC pointer dx=$moved2 px (before x=$($before2[0]), after x=$($after2[0]))"
|
||||
if ($moved2 -ge 0) { throw "V-B1a FAIL: leftward swipe did not move the pointer left (dx=$moved2)" }
|
||||
Write-Host " PASS: negative direction correct (sign preserved)"
|
||||
|
||||
Write-Host "=== 2c. relative mode does NOT teleport the pointer on touch-down ==="
|
||||
# The defining property of relative mode: ACTION_DOWN must not send an
|
||||
# absolute move. Tap at the far LEFT of the view; the pointer must not jump
|
||||
# to the left edge of the desktop.
|
||||
#
|
||||
# HARNESS DISCIPLINE (measured 2026-10-31): a swipe's final relative events
|
||||
# can still be in flight when we re-park the cursor. The first attempt showed
|
||||
# phantom "jumps" of 15px and 298px that were really the PREVIOUS swipe's
|
||||
# queued deltas landing after our SetCursorPos. So: wait for the link to go
|
||||
# quiet, then require the baseline to be STABLE across two reads before
|
||||
# judging. Otherwise this test would report a product defect that isn't one.
|
||||
Start-Sleep -Milliseconds 1200
|
||||
[PC]::Set([int]($SW*0.8), [int]($SH/2))
|
||||
Start-Sleep -Milliseconds 500
|
||||
$stable = $false
|
||||
for ($i=0; $i -lt 6; $i++) {
|
||||
$r1 = [PC]::Get(); Start-Sleep -Milliseconds 350; $r2 = [PC]::Get()
|
||||
if ($r1[0] -eq $r2[0] -and $r1[1] -eq $r2[1]) { $stable = $true; break }
|
||||
Write-Host " cursor still settling: ($($r1[0]),$($r1[1])) -> ($($r2[0]),$($r2[1]))"
|
||||
}
|
||||
if (-not $stable) { throw "cannot obtain a stable cursor baseline (residual relative events never drained)" }
|
||||
$beforeTap = [PC]::Get()
|
||||
$tapX = [int]($vx1 + ($vx2-$vx1)*0.05)
|
||||
[void](AdbShell @("input","tap","$tapX","$vcy"))
|
||||
Start-Sleep -Milliseconds 700
|
||||
$afterTap = [PC]::Get()
|
||||
$jump = [Math]::Max([Math]::Abs($afterTap[0] - $beforeTap[0]), [Math]::Abs($afterTap[1] - $beforeTap[1]))
|
||||
$dbgTap = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||
Write-Host " tap at view-left x=$tapX ; pointer ($($beforeTap[0]),$($beforeTap[1])) -> ($($afterTap[0]),$($afterTap[1])) jump=$jump px"
|
||||
if ($dbgTap.Contains($F_LONGPRESS)) { throw "tap was interpreted as a long press; harness timing is wrong" }
|
||||
if ($jump -gt 8) { throw "V-B1a FAIL: touch-down teleported the pointer by $jump px (absolute move leaked into relative mode)" }
|
||||
Write-Host " PASS: relative mode leaves the pointer where it was (no teleport)"
|
||||
|
||||
# =================================================================
|
||||
Write-Host "=== 3. V-B2: switch to ABSOLUTE mode, re-run R7 touch mapping ==="
|
||||
[void](Set-Mode "absolute")
|
||||
$hud2 = ""
|
||||
$m2 = [regex]::Match((Get-UiText),'text="([^"]*RTT=[^"]*)"')
|
||||
if ($m2.Success) { $hud2 = [System.Net.WebUtility]::HtmlDecode($m2.Groups[1].Value) }
|
||||
Write-Host " HUD: $hud2"
|
||||
if (-not $hud2.Contains($F_ABSOLUTE)) { throw "V-B2 FAIL: HUD still does not say absolute; got [$hud2]" }
|
||||
Write-Host " PASS: mode switched to absolute (button label and HUD agree)"
|
||||
|
||||
# R7 expectations for 1920x1080 (physical pixels, 125% scaling does NOT apply
|
||||
# to the injected desktop space - the agent injects into the physical desktop)
|
||||
$r7 = @(
|
||||
@{ frac=0.10; ex=[int](192); ey=[int](108) },
|
||||
@{ frac=0.90; ex=[int](1728); ey=[int](972) },
|
||||
@{ frac=0.50; ex=[int](960); ey=[int](540) }
|
||||
)
|
||||
$worst = 0
|
||||
foreach ($c in $r7) {
|
||||
$tx = [int]($vx1 + ($vx2-$vx1)*$c.frac)
|
||||
$ty = [int]($vy1 + ($vy2-$vy1)*$c.frac)
|
||||
[void](AdbShell @("input","tap","$tx","$ty"))
|
||||
Start-Sleep -Milliseconds 400
|
||||
$p = [PC]::Get()
|
||||
$errX = [Math]::Abs($p[0] - $c.ex)
|
||||
$errY = [Math]::Abs($p[1] - $c.ey)
|
||||
$e = [Math]::Max($errX,$errY)
|
||||
if ($e -gt $worst) { $worst = $e }
|
||||
Write-Host " frac=$($c.frac) expect=($($c.ex),$($c.ey)) actual=($($p[0]),$($p[1])) err=($errX,$errY)"
|
||||
}
|
||||
Write-Host " worst R7 error = $worst px"
|
||||
if ($worst -gt 8) { throw "V-B2 FAIL: R7 absolute mapping error $worst px > 8px" }
|
||||
Write-Host " PASS: absolute mode still maps 1:1 (R7 regression green)"
|
||||
|
||||
# =================================================================
|
||||
Write-Host "=== 4. V-B3: long press opens a context menu; drag does NOT ==="
|
||||
# Switch back to relative for the long-press test (that is the default mode
|
||||
# users get, and the gesture must work there).
|
||||
[void](Set-Mode "relative")
|
||||
|
||||
# Use a desktop of our own: the proof's WinForms target window (R-11: never
|
||||
# inject into a window we do not own - it would modify user data).
|
||||
$target = Join-Path $repo "tools\text_target.ps1"
|
||||
if (-not (Test-Path $target)) { throw "missing $target" }
|
||||
$tp = Start-Process powershell -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-File",$target `
|
||||
-PassThru -WindowStyle Minimized
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " target window started (pid $($tp.Id))"
|
||||
|
||||
# Park the pointer over the middle of the target window's edit area by
|
||||
# absolute injection first (so we know where the long press lands).
|
||||
[PC]::Set([int]($SW/2), [int]($SH/2))
|
||||
Start-Sleep -Milliseconds 300
|
||||
$preLong = [PC]::Get()
|
||||
$rcBefore = RightClickCount
|
||||
Write-Host " right-click counter before: $rcBefore"
|
||||
[void](AdbShell @("input","swipe","$vcx","$vcy","$vcx","$vcy","900")) # press & hold ~900ms, no movement
|
||||
Start-Sleep -Milliseconds 700
|
||||
$rcAfter = RightClickCount
|
||||
$debug = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||
& powershell -NoProfile -ExecutionPolicy Bypass -File "$repo\tools\shot.ps1" -Out "$ev\v-b3-longpress.png" | Out-Null
|
||||
Write-Host " right-click counter after : $rcAfter (source tag in HUD)"
|
||||
Write-Host " screen after long press: $debug"
|
||||
if ($rcBefore -lt 0 -or $rcAfter -lt 0) { throw "V-B3 FAIL: could not read the right-click counter from the HUD" }
|
||||
if ($rcAfter -le $rcBefore) {
|
||||
throw "V-B3 FAIL: long press did not inject a right click (counter stayed $rcAfter; screen: $debug)"
|
||||
}
|
||||
Write-Host " PASS: long press injected a right click (counter $rcBefore -> $rcAfter)"
|
||||
|
||||
Write-Host "=== 4b. V-B3 negative: a DRAG must not fire long press ==="
|
||||
$rcBeforeDrag = RightClickCount
|
||||
[void](AdbShell @("input","swipe","$vcx","$vcy",$([int]($vcx+120)),$([int]($vcy+80)),900))
|
||||
Start-Sleep -Milliseconds 700
|
||||
$rcAfterDrag = RightClickCount
|
||||
$debug2 = ((VisibleTexts (Get-UiText)) -join " || ")
|
||||
Write-Host " right-click counter after drag: $rcAfterDrag (was $rcBeforeDrag)"
|
||||
Write-Host " screen after drag: $debug2"
|
||||
if ($rcAfterDrag -gt $rcBeforeDrag) {
|
||||
throw "V-B3 FAIL: drag injected a right click (counter $rcBeforeDrag -> $rcAfterDrag); long press must be suppressed once the finger moves"
|
||||
}
|
||||
Write-Host " PASS: drag did not fire long press"
|
||||
|
||||
Write-Host "=== 4c. V-B3: two-finger right click still works ==="
|
||||
$rcBeforeTwo = RightClickCount
|
||||
# adb cannot synthesize a two-finger tap (lessons R-6): this is a MANUAL item.
|
||||
Write-Host " (two-finger tap is not drivable via adb - recorded as manual, not asserted here)"
|
||||
|
||||
Stop-Process -Id $tp.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
finally {
|
||||
[void][PC]::Set($origCursor[0], $origCursor[1])
|
||||
[void](AdbShell @("svc","power","stayon","false"))
|
||||
if ($origIme -and $origIme -ne "null") {
|
||||
[void](AdbShell @("ime","enable",$origIme))
|
||||
[void](AdbShell @("ime","set",$origIme))
|
||||
Write-Host " IME restored: $(((AdbShell @('settings','get','secure','default_input_method')) -join '').Trim())"
|
||||
}
|
||||
Write-Host " PC cursor restored to ($($origCursor[0]),$($origCursor[1]))"
|
||||
}
|
||||
|
||||
Write-Host "=== REL MOUSE PHONE PROOF COMPLETE ==="
|
||||
@@ -20,3 +20,11 @@ rustls.workspace = true
|
||||
# headless_check 用软解交叉验证收到的码流合法(库本身刻意不依赖 openh264,
|
||||
# 解码在 Android 侧由 MediaCodec 承担)
|
||||
openh264 = { version = "0.9.9", features = ["source"] }
|
||||
# rel_mouse_proof(3b/V-B1)需要读回 Windows 光标位置来闭环验证相对注入。
|
||||
# 仅 examples 使用,不进库本体;库本身仍然平台无关。
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
windows = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_HiDpi",
|
||||
] }
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 3b 桌面验收:相对鼠标(协议 v4)的像素级闭环。
|
||||
//!
|
||||
//! 为什么必须单独写一个 proof:V-B1 的判据是「位移**恰好**等于请求值」,
|
||||
//! 而 Android 侧的真机脚本只能看到 UI 文本,看不到远端指针实际走了多少像素。
|
||||
//! 这里直接走完整链路:viewer-core 发 `MouseMoveRel` → QUIC → agent 注入 →
|
||||
//! `GetCursorPos` 读回,逐项比对。
|
||||
//!
|
||||
//! 覆盖判据:
|
||||
//! - V-B1a 方向与量值:dx=+50 → +50px,dx=-50 → -50px(误差 ≤1px)
|
||||
//! - V-B1b 慢速不丢位移:连发 20 次 dx=+1 → 累计 +20px(误差 ≤2px)
|
||||
//! - V-B1c 撞边不积债:右边界 +200 后 -50 → 立即 -50
|
||||
//!
|
||||
//! 用法(agent 需已启动):
|
||||
//! cargo run --release -p viewer-core --example rel_mouse_proof -- --host 127.0.0.1 --port 47800 --code t
|
||||
//!
|
||||
//! 安全:运行前记录光标原位,结束时还原(不污染用户桌面状态)。
|
||||
|
||||
use std::time::Duration;
|
||||
use viewer_core::{ConnectConfig, Session};
|
||||
|
||||
fn arg(name: &str) -> Option<String> {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
a.iter()
|
||||
.position(|x| x == name)
|
||||
.and_then(|i| a.get(i + 1))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 读当前光标(物理像素)。用 windows crate 而不是 shell,避免进程 DPI 差异。
|
||||
fn cursor() -> (i32, i32) {
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||
let mut p = POINT::default();
|
||||
unsafe {
|
||||
let _ = GetCursorPos(&mut p);
|
||||
}
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn set_cursor(x: i32, y: i32) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::SetCursorPos;
|
||||
unsafe {
|
||||
let _ = SetCursorPos(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_size() -> (i32, i32) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
|
||||
unsafe { (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)) }
|
||||
}
|
||||
|
||||
fn enable_dpi_awareness() {
|
||||
use windows::Win32::UI::HiDpi::{
|
||||
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
||||
};
|
||||
unsafe {
|
||||
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一串相对增量,每次之间留出注入完成的时间,然后读回光标。
|
||||
fn apply_rel(session: &Session, steps: &[(i16, i16)], settle_ms: u64) {
|
||||
for &(dx, dy) in steps {
|
||||
session.send_mouse_rel(dx, dy);
|
||||
// 让 QUIC 上行 + agent 注入 + 光标更新都完成;太小会读到中间态
|
||||
std::thread::sleep(Duration::from_millis(settle_ms));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
enable_dpi_awareness();
|
||||
let port: u16 = arg("--port").map(|s| s.parse()).transpose()?.unwrap_or(47800);
|
||||
let code = arg("--code").unwrap_or_else(|| "t".into());
|
||||
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
|
||||
|
||||
let (sw, sh) = screen_size();
|
||||
println!("[proof] screen {sw}x{sh} (physical, DPI-aware)");
|
||||
|
||||
let session = Session::connect(ConnectConfig::direct(&host, port, code)).await?;
|
||||
let (dw, dh) = session.desktop_size();
|
||||
println!("[proof] agent desktop {dw}x{dh}");
|
||||
|
||||
// 记录原位,结束时还原(不污染桌面状态)
|
||||
let (ox, oy) = cursor();
|
||||
println!("[proof] cursor origin ({ox},{oy})");
|
||||
|
||||
let mut failures = 0usize;
|
||||
|
||||
// ---- V-B1a 方向与量值 ----
|
||||
println!("\n=== V-B1a direction & magnitude ===");
|
||||
for &delta in &[50i16, -50i16, 120i16, -120i16] {
|
||||
// 每次都从屏幕中心出发,避免被边界 clamp 干扰量值判定
|
||||
set_cursor(sw / 2, sh / 2);
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
let (bx, _) = cursor();
|
||||
apply_rel(&session, &[(delta, 0)], 80);
|
||||
let (ax, _) = cursor();
|
||||
let applied = ax - bx;
|
||||
let ok = (applied - delta as i32).abs() <= 1;
|
||||
println!(
|
||||
" dx={delta:+5} from x={bx:4} to x={ax:4} applied={applied:+5} {}",
|
||||
if ok { "PASS" } else { "FAIL" }
|
||||
);
|
||||
if !ok {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 纵向同理(同一套代码路径,但轴不同,clamp 上界不同)
|
||||
for &delta in &[40i16, -40i16] {
|
||||
set_cursor(sw / 2, sh / 2);
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
let (_, by) = cursor();
|
||||
apply_rel(&session, &[(0, delta)], 80);
|
||||
let (_, ay) = cursor();
|
||||
let applied = ay - by;
|
||||
let ok = (applied - delta as i32).abs() <= 1;
|
||||
println!(
|
||||
" dy={delta:+5} from y={by:4} to y={ay:4} applied={applied:+5} {}",
|
||||
if ok { "PASS" } else { "FAIL" }
|
||||
);
|
||||
if !ok {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- V-B1b 慢速小幅不丢位移 ----
|
||||
println!("\n=== V-B1b slow small steps must not be lost ===");
|
||||
{
|
||||
set_cursor(sw / 2, sh / 2);
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
let (bx, _) = cursor();
|
||||
// 20 次 dx=+1:若注入层按 mickey 透传,dx=1 会被加速吞成 0 像素(实测如此)
|
||||
let steps: Vec<(i16, i16)> = (0..20).map(|_| (1i16, 0i16)).collect();
|
||||
apply_rel(&session, &steps, 45);
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
let (ax, _) = cursor();
|
||||
let applied = ax - bx;
|
||||
let ok = (applied - 20).abs() <= 2;
|
||||
println!(" 20 x dx=+1 from x={bx} to x={ax} applied={applied:+} (expect +20) {}",
|
||||
if ok { "PASS" } else { "FAIL" });
|
||||
if !ok {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- V-B1c 撞边不积债 ----
|
||||
println!("\n=== V-B1c edge clamp leaves no step debt ===");
|
||||
{
|
||||
set_cursor(sw - 1, sh / 2);
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
let (bx, _) = cursor();
|
||||
apply_rel(&session, &[(200, 0)], 80);
|
||||
let (mx, _) = cursor();
|
||||
let clamped = mx - bx;
|
||||
// 回滑必须立即生效(若累积器"记住"了被 clamp 丢掉的 200px,这里会先空转)
|
||||
apply_rel(&session, &[(-50, 0)], 80);
|
||||
let (ax, _) = cursor();
|
||||
let back = ax - mx;
|
||||
let ok = clamped == 0 && (back + 50).abs() <= 1;
|
||||
println!(
|
||||
" at right edge x={bx}: +200 -> applied {clamped:+} (clamped); -50 -> applied {back:+} {}",
|
||||
if ok { "PASS" } else { "FAIL" }
|
||||
);
|
||||
if !ok {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 还原光标
|
||||
set_cursor(ox, oy);
|
||||
|
||||
println!("\n========== RESULT ==========");
|
||||
if failures == 0 {
|
||||
println!("REL MOUSE PROOF PASS (all V-B1a/b/c checks)");
|
||||
} else {
|
||||
println!("REL MOUSE PROOF FAIL ({failures} checks failed)");
|
||||
}
|
||||
session.close().await;
|
||||
if failures > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -618,6 +618,21 @@ impl Session {
|
||||
self.send_input(InputEvent::Text { units });
|
||||
}
|
||||
|
||||
/// 协议 v4:发送**相对**鼠标位移(单位:被控端桌面物理像素)。
|
||||
///
|
||||
/// 与 [`Session::send_text`] 同样的空值纪律:零位移不占一条消息——它没有注入语义,
|
||||
/// 只会白占一个 QUIC 帧(触摸事件里 `dx==0 && dy==0` 是常态,手指静止时每帧都发)。
|
||||
///
|
||||
/// 语义是「从远端指针当前位置移动 (dx,dy)」,远端负责 clamp 到桌面内。
|
||||
/// 主控端**不要**先算绝对坐标再发——那就退化成绝对模式,失去相对鼠标的意义
|
||||
/// (本机真鼠标同时移动时会跳变)。
|
||||
pub fn send_mouse_rel(&self, dx: i16, dy: i16) {
|
||||
if dx == 0 && dy == 0 {
|
||||
return;
|
||||
}
|
||||
self.send_input(InputEvent::MouseMoveRel { dx, dy });
|
||||
}
|
||||
|
||||
/// 请求被控端强制关键帧(解码器错误/跳帧后的恢复路径)。
|
||||
pub fn request_keyframe(&self) {
|
||||
let _ = self.tx.send(Command::KeyFrameReq);
|
||||
|
||||
Reference in new issue
Block a user