Compare commits
4 Commits
99ffffa393
...
bb3cf6b09b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb3cf6b09b | ||
|
|
718b66617f | ||
|
|
f28db050fb | ||
|
|
4eb82f9779 |
@ -34,7 +34,8 @@ struct SessionBox {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
/// 连接被控端。成功返回句柄(>0),失败返回 0;错误消息经 `nativeLastError()` 取。
|
||||
/// 连接被控端。成功返回句柄(>0),失败返回 0;错误消息经 `nativeLastError()` 取,
|
||||
/// **错误分类**经 `nativeLastErrorKind()` 取(3a:界面按分类显示中文文案,原始英文作第二行)。
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
@ -46,14 +47,14 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
|
||||
let host_s = match env.get_string(&host) {
|
||||
Ok(s) => s.to_string_lossy().into_owned(),
|
||||
Err(_) => {
|
||||
set_last_error("host 参数不是合法字符串");
|
||||
set_last_error(-1, "host 参数不是合法字符串");
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
let code_s = match env.get_string(&code) {
|
||||
Ok(s) => s.to_string_lossy().into_owned(),
|
||||
Err(_) => {
|
||||
set_last_error("code 参数不是合法字符串");
|
||||
set_last_error(-1, "code 参数不是合法字符串");
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
@ -64,7 +65,7 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
|
||||
.build()
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(format!("runtime init: {e}")),
|
||||
Err(e) => return Err((-1i32, format!("runtime init: {e}"))),
|
||||
};
|
||||
let cfg = ConnectConfig::direct(host_s, port as u16, code_s);
|
||||
match runtime.block_on(Session::connect(cfg)) {
|
||||
@ -72,53 +73,73 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
|
||||
session: Arc::new(session),
|
||||
runtime,
|
||||
}),
|
||||
Err(e) => Err(format!("{e:#}")),
|
||||
// ConnectError 的 Display 即原始 detail;kind.code() 与
|
||||
// viewer_core::ConnectErrorKind::code 的映射表一致(FFI 契约)。
|
||||
Err(e) => Err((e.kind.code(), e.detail)),
|
||||
}
|
||||
});
|
||||
|
||||
match built {
|
||||
Ok(Ok(boxed)) => {
|
||||
set_last_error("");
|
||||
set_last_error(0, "");
|
||||
Box::into_raw(Box::new(Mutex::new(boxed))) as jlong
|
||||
}
|
||||
Ok(Err(msg)) => {
|
||||
set_last_error(&msg);
|
||||
Ok(Err((kind, msg))) => {
|
||||
set_last_error(kind, &msg);
|
||||
0
|
||||
}
|
||||
Err(_) => {
|
||||
set_last_error("panic in nativeConnect");
|
||||
set_last_error(-1, "panic in nativeConnect");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_last_error(msg: &str) {
|
||||
/// kind = 0 表示清除;-1 表示 FFI 层自身错误(无 viewer-core 分类,Kotlin 走 Other 文案)。
|
||||
fn set_last_error(kind: i32, msg: &str) {
|
||||
let mut slot = LAST_ERROR.lock().unwrap();
|
||||
if msg.is_empty() {
|
||||
*slot = None;
|
||||
} else {
|
||||
*slot = Some(msg.to_string());
|
||||
*slot = Some((kind, msg.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
|
||||
static LAST_ERROR: Mutex<Option<(i32, String)>> = Mutex::new(None);
|
||||
|
||||
/// 取最近一次 nativeConnect 的错误消息(无错误返回 null)。
|
||||
/// FFI 契约:调用方必须先 `nativeLastErrorKind()` 再 `nativeLastError()`——
|
||||
/// 本函数读走消息时**清空整个槽位**(kind 一起失效),颠倒顺序会拿到 kind=0。
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastError<'local>(
|
||||
env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
) -> jstring {
|
||||
let msg = LAST_ERROR.lock().unwrap().take();
|
||||
let mut slot = LAST_ERROR.lock().unwrap();
|
||||
let msg = slot.as_ref().map(|(_, m)| m.clone());
|
||||
match msg {
|
||||
Some(m) => env
|
||||
.new_string(m)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut()),
|
||||
Some(m) => {
|
||||
*slot = None;
|
||||
env.new_string(m)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
None => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取最近一次 nativeConnect 的错误分类码(viewer_core::ConnectErrorKind::code)。
|
||||
/// 只读不清空;0 = 无错误,-1 = FFI 层自身错误(参数非法/panic/runtime 初始化失败,
|
||||
/// 无 viewer-core 分类,Kotlin 按 Other 文案 + 原始消息显示)。
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastErrorKind(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jint {
|
||||
let slot = LAST_ERROR.lock().unwrap();
|
||||
slot.as_ref().map(|(k, _)| *k).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 取下一帧(阻塞直到有帧或会话结束)。返回 null = 会话结束。
|
||||
/// 仅允许解码线程调用;destroy 前必须先停止本线程(见文件头锁纪律)。
|
||||
#[no_mangle]
|
||||
|
||||
@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.verify.remote"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1-phase1"
|
||||
versionCode = 2
|
||||
versionName = "0.1-phase3a"
|
||||
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
@ -25,6 +25,15 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// 3a/U7:安卓测试基础设施(Q3=A,用户 2026-10-31 批准)。
|
||||
// **仅测试作用域**:junit 不进 APK,runtime dependencies 保持为空。
|
||||
testOptions {
|
||||
unitTests {
|
||||
// android.jar 桩方法抛 "not mocked"——本工程的单测刻意只测纯逻辑
|
||||
// (不构造 Android 对象),此开关只兜常量引用类的类加载副作用。
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
@ -53,4 +62,6 @@ android {
|
||||
dependencies {
|
||||
// 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。
|
||||
// AndroidX 在阶段 2 做正式 UI 时再引入。
|
||||
// 3a/U7(Q3=A 批准):仅 JUnit 测试作用域,不进 APK。
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package com.verify.remote
|
||||
|
||||
/**
|
||||
* 连接失败分类 → 用户可执行的中文提示(阶段 3a,U8/N6 闭环)。
|
||||
*
|
||||
* 分类在 Rust 侧做(`viewer_core::ConnectErrorKind`),这里只做编号→文案映射。
|
||||
* 编号契约与 Rust 的 `code()` 一一对应(`android-ffi/src/lib.rs` 文件头);
|
||||
* Kotlin 不 import Rust 类型,跨 FFI 只传整数,所以本对象是纯逻辑、可 JVM 单测。
|
||||
*
|
||||
* 文案纪律:每条都必须**告诉用户下一步做什么**(方案 §3.4:不能停在「连接中」),
|
||||
* 原始英文错误由 UI 作第二行展示(排障用,两者不互相替代)。
|
||||
*/
|
||||
object ConnectErrorText {
|
||||
/** FFI 编号。与 viewer_core::ConnectErrorKind::code 同步,改动必须两侧一起改 */
|
||||
const val KIND_NONE = 0
|
||||
const val KIND_FFI_SELF = -1 // FFI 层自身错误(参数/panic/runtime),无业务分类
|
||||
const val KIND_RESOLVE = 1
|
||||
const val KIND_TIMEOUT = 2
|
||||
const val KIND_REFUSED = 3
|
||||
const val KIND_AUTH = 4
|
||||
const val KIND_VERSION = 5
|
||||
const val KIND_OTHER = 9
|
||||
|
||||
/** kind 未知(编号漂移)时返回 null,调用方必须落到带原始错误的兜底文案,不得静默 */
|
||||
fun summary(kind: Int): String? = when (kind) {
|
||||
KIND_RESOLVE -> "无法解析这个地址:检查 IP 是否写对"
|
||||
KIND_TIMEOUT -> "连接超时:被控端不在线或入站被拦(检查 agent 是否在跑;本机联调需防火墙放行 47800-47810)"
|
||||
KIND_REFUSED -> "被控端应答异常:确认这个端口上跑的是 agent"
|
||||
KIND_AUTH -> "验证码错误:请向被控端核对验证码"
|
||||
KIND_VERSION -> "协议版本不一致:两端都要更新到同一版本"
|
||||
KIND_FFI_SELF, KIND_OTHER -> "连接失败"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** 完整首行:有分类文案用分类,未知编号不猜语义 */
|
||||
fun firstLine(kind: Int): String =
|
||||
summary(kind) ?: "连接失败(未知错误类型 $kind)"
|
||||
}
|
||||
@ -57,6 +57,15 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
@Volatile
|
||||
private var connecting = false
|
||||
|
||||
/**
|
||||
* 会话代际(3a):startConnect/cancel/disconnect/destroy 全部递增。
|
||||
* connect 线程完成时若代际已变(用户取消、断开、退出),结果**作废**——
|
||||
* 成功的连接立即在本线程 destroy(此时解码线程还不存在,满足
|
||||
* 「destroy 前必须没有解码线程」的锁纪律),失败的错误也不再上屏。
|
||||
*/
|
||||
@Volatile
|
||||
private var sessionGeneration = 0
|
||||
|
||||
@Volatile
|
||||
private var remoteW = 0
|
||||
@Volatile
|
||||
@ -128,8 +137,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
// 验证码刻意不从 prefs 读(不落盘)
|
||||
codeInput = editText("验证码", InputType.TYPE_CLASS_TEXT, "")
|
||||
connectButton = Button(this).apply {
|
||||
// 三态复用同一按钮:连接 / 取消(等待中)/ 重新连接(失败或断开后)
|
||||
text = "连接"
|
||||
setOnClickListener { startConnect() }
|
||||
setOnClickListener { onConnectButtonClicked() }
|
||||
}
|
||||
connectStatus = TextView(this).apply {
|
||||
textSize = 13f
|
||||
@ -237,13 +247,22 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
* 显式唤起软键盘。不做自动弹:自动弹会挡画面,且 vivo ROM 行为不一致。
|
||||
* SurfaceView 已在触摸模式下可聚焦(见 RemoteSurfaceView.init),
|
||||
* 否则系统不会建立 InputConnection。
|
||||
*
|
||||
* 键盘可见时再点 = 收起(否则用户没有任何收起入口,BACK 语义已归会话管理)。
|
||||
*/
|
||||
private fun showKeyboard() {
|
||||
if (handle == 0L) return
|
||||
surfaceView.requestFocus()
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
if (keyboardVisible) {
|
||||
imm.hideSoftInputFromWindow(surfaceView.windowToken, 0)
|
||||
keyboardVisible = false
|
||||
lastTouchDbg = "键盘已收起"
|
||||
refreshStatus()
|
||||
return
|
||||
}
|
||||
surfaceView.requestFocus()
|
||||
imm.showSoftInput(surfaceView, InputMethodManager.SHOW_IMPLICIT)
|
||||
lastTouchDbg = "showSoftInput 已请求(焦点=${surfaceView.hasFocus()})"
|
||||
lastTouchDbg = "键盘已请求"
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
@ -343,6 +362,14 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
keyboardVisible = false
|
||||
return true
|
||||
}
|
||||
if (connecting) {
|
||||
// 连接等待中(handle 还没回填):BACK = 取消并回表单,**不结束 Activity**。
|
||||
// 3a/V-A4:修复「连接中 BACK 让 Activity 可能被 super 关掉,
|
||||
// 而 connect 线程随后仍回调界面」的悬挂路径。再按一次 BACK(表单态)才退出。
|
||||
cancelConnect()
|
||||
remotePanelVisible(false)
|
||||
return true
|
||||
}
|
||||
if (handle != 0L) {
|
||||
disconnect()
|
||||
return true
|
||||
@ -351,23 +378,23 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
}
|
||||
val h = handle
|
||||
if (h == 0L || event == null) return super.onKeyDown(keyCode, event)
|
||||
// 只报 keyCode 与路由结果,不报字符内容(诊断通道会显示在屏幕上)
|
||||
// 只报事件去向,不报字符内容(诊断通道会显示在屏幕上);坐标类验证在 PC 侧闭环
|
||||
val cp = event.unicodeChar
|
||||
if (cp >= 0x20 && cp != 0x7F) {
|
||||
if (event.repeatCount == 0) RustBridge.nativeSendText(h, charArrayOf(cp.toChar()))
|
||||
lastTouchDbg = "key code=$keyCode -> text 1u"
|
||||
lastTouchDbg = "键→文本 1u"
|
||||
runOnUiThread { refreshStatus() }
|
||||
return true
|
||||
}
|
||||
val key = WinScancode.of(keyCode) ?: run {
|
||||
lastTouchDbg = "key code=$keyCode 未映射"
|
||||
lastTouchDbg = "键 keyCode=$keyCode 未映射"
|
||||
runOnUiThread { refreshStatus() }
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
if (event.repeatCount == 0) {
|
||||
RustBridge.nativeSendKey(h, key.scancode, if (key.extended) 1 else 0, 1)
|
||||
}
|
||||
lastTouchDbg = "key code=$keyCode -> scan ${key.scancode} ext=${key.extended}"
|
||||
lastTouchDbg = "键→scan 0x${key.scancode.toString(16)}"
|
||||
runOnUiThread { refreshStatus() }
|
||||
return true
|
||||
}
|
||||
@ -389,6 +416,26 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
|
||||
// ---- 连接 / 解码 ----
|
||||
|
||||
/** 按钮三态复用:等待中→取消;空闲/失败→发起连接 */
|
||||
private fun onConnectButtonClicked() {
|
||||
if (connecting) cancelConnect() else startConnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消等待中的连接(3a/V-A1)。不做真正的中断(JNI block_on 不可打断),
|
||||
* 而是代际作废 + 立即回表单:connect 线程最多再等分级预算自灭
|
||||
* (握手 5s;已握手后排队认证 11s),成功返回的孤儿句柄由该线程就地
|
||||
* destroy(无解码线程,锁纪律成立)。
|
||||
*/
|
||||
private fun cancelConnect() {
|
||||
if (!connecting) return
|
||||
sessionGeneration++
|
||||
connecting = false
|
||||
connectButton.isEnabled = true
|
||||
connectButton.text = "重新连接"
|
||||
showConnectState("连接已取消(后台最多再跑 ~14s 自动作废,不影响再次连接)")
|
||||
}
|
||||
|
||||
private fun startConnect() {
|
||||
if (connecting || handle != 0L) return
|
||||
val host = hostInput.text.toString().trim()
|
||||
@ -409,21 +456,36 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
}
|
||||
}
|
||||
connecting = true
|
||||
connectButton.isEnabled = false
|
||||
val gen = ++sessionGeneration
|
||||
connectButton.text = "取消"
|
||||
showRemoteState()
|
||||
statusView.text = "连接 $host:$port …"
|
||||
thread(name = "connect") {
|
||||
try {
|
||||
// 分阶段预算(viewer-core):握手 5s(不可达快速失败),
|
||||
// 认证成功排队最长 11s(agent 串行 accept,前会话 ~10s 释放)。
|
||||
val h = RustBridge.connect(host, port, code)
|
||||
// 尺寸必须在解码前拿到:用它定 SurfaceView 宽高比。
|
||||
// 若等首帧再改,视图会重新测量,把刚建好的解码器 surface 打掉。
|
||||
if (gen != sessionGeneration) {
|
||||
// 已被取消/断开:这里没有解码线程在用 h,本线程 destroy 安全
|
||||
RustBridge.destroy(h)
|
||||
return@thread
|
||||
}
|
||||
val (w, ht) = RustBridge.desktopSize(h)
|
||||
handle = h
|
||||
remoteW = w
|
||||
remoteH = ht
|
||||
connecting = false
|
||||
// 状态写入全部收敛到 UI 线程:connecting/handle/按钮的读写与
|
||||
// 取消/BACK/disconnect 同线程串行,杜绝「cancel 竞态留下孤儿 handle」
|
||||
runOnUiThread {
|
||||
connectButton.isEnabled = true
|
||||
if (gen != sessionGeneration) {
|
||||
// UI 线程里 cancel 的原子复查;h 尚无解码线程,直接 destroy
|
||||
RustBridge.destroy(h)
|
||||
return@runOnUiThread
|
||||
}
|
||||
connecting = false
|
||||
handle = h
|
||||
remoteW = w
|
||||
remoteH = ht
|
||||
connectButton.text = "断开"
|
||||
// 只有认证成功的会话才值得记住;验证码不落盘(用户裁决)
|
||||
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
@ -434,73 +496,157 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
surfaceView.videoAspectRatio = w.toFloat() / ht
|
||||
}
|
||||
refreshStatus()
|
||||
startDecodeLoop(h)
|
||||
startDecodeLoop(h, gen)
|
||||
}
|
||||
} catch (e: ConnectFailure) {
|
||||
Log.e(TAG, "connect failed kind=${e.kind}", e)
|
||||
if (gen != sessionGeneration) return@thread // 已取消:错误不再上屏
|
||||
val first = ConnectErrorText.firstLine(e.kind)
|
||||
runOnUiThread {
|
||||
if (gen != sessionGeneration) return@runOnUiThread
|
||||
connecting = false
|
||||
handle = 0L
|
||||
connectButton.isEnabled = true
|
||||
connectButton.text = "重新连接"
|
||||
// 第一行=分类中文提示,第二行=原始错误(V-A2 判据:两者都在)
|
||||
showConnectState("$first\n detail: ${e.detail}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connect failed", e)
|
||||
Log.e(TAG, "connect failed (unexpected)", e)
|
||||
if (gen != sessionGeneration) return@thread
|
||||
val msg = e.message ?: "连接失败"
|
||||
runOnUiThread {
|
||||
connectButton.isEnabled = true
|
||||
if (gen != sessionGeneration) return@runOnUiThread
|
||||
connecting = false
|
||||
handle = 0L
|
||||
showConnectState("连接失败:$msg")
|
||||
connectButton.isEnabled = true
|
||||
connectButton.text = "重新连接"
|
||||
showConnectState("连接失败\n detail: $msg")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDecodeLoop(h: Long) {
|
||||
/**
|
||||
* 解码循环。所有权模型与 2b 一致:**destroy 只在 UI 线程**(置 stopDecoding →
|
||||
* join → destroy);本线程只负责取帧/解码,检测到会话断时把收尾 post 回 UI,
|
||||
* 由代际(gen)与 handle 双重复核后执行。本线程绝不自等 join、绝不并发 destroy。
|
||||
*/
|
||||
private fun startDecodeLoop(h: Long, gen: Int) {
|
||||
stopDecoding = false
|
||||
decodeThread = thread(name = "decode") {
|
||||
val surface = surfaceView.holder.surface
|
||||
var tries = 0
|
||||
while (!surface.isValid && tries++ < 100) Thread.sleep(50)
|
||||
while (!surface.isValid && tries++ < 100 && !stopDecoding) Thread.sleep(50)
|
||||
if (!surface.isValid) {
|
||||
Log.e(TAG, "surface never became valid")
|
||||
if (!stopDecoding) {
|
||||
// 可见后果:画面永远起不来也要回表单报错,不能静默停在「已连接」
|
||||
runOnUiThread {
|
||||
if (gen == sessionGeneration && handle == h) {
|
||||
cleanupSession(h, "画面启动失败:Surface 不可用,请重新连接")
|
||||
}
|
||||
}
|
||||
}
|
||||
return@thread
|
||||
}
|
||||
val dec = H264Decoder(surface)
|
||||
var frames = 0L
|
||||
var lastKeyReq = 0L
|
||||
var windowFrames = 0L
|
||||
var windowStart = System.currentTimeMillis()
|
||||
var lostReason: String? = null
|
||||
while (!stopDecoding) {
|
||||
val f = try {
|
||||
RustBridge.nextFrame(h)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "nextFrame threw", e)
|
||||
null
|
||||
} ?: break
|
||||
}
|
||||
if (f == null) {
|
||||
if (!stopDecoding) lostReason = "被控端不可达或会话已结束"
|
||||
break
|
||||
}
|
||||
val fed = dec.queue(f)
|
||||
if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) {
|
||||
RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求
|
||||
lastKeyReq = System.currentTimeMillis()
|
||||
}
|
||||
frames++
|
||||
if (frames % 120 == 0L) {
|
||||
windowFrames++
|
||||
// HUD 每 1s 窗口刷一次:fps 是窗口内帧数(3a/V-A5)
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - windowStart >= 1000) {
|
||||
lastFps = windowFrames * 1000 / (now - windowStart)
|
||||
windowFrames = 0
|
||||
windowStart = now
|
||||
lastFrames = frames
|
||||
lastRtt = RustBridge.rttMs(h)
|
||||
runOnUiThread { refreshStatus() }
|
||||
}
|
||||
}
|
||||
dec.release()
|
||||
Log.i(TAG, "decode loop ended, frames=$frames")
|
||||
val reason = lostReason
|
||||
Log.i(TAG, "decode loop ended, frames=$frames lost=${reason != null}")
|
||||
if (reason != null) {
|
||||
// 断线可见(3a/V-A3,修复「冻结在已连接」):收尾回 UI 线程执行。
|
||||
// 本线程 post 后继续走到退出——UI 侧 cleanupSession 的 join 等的
|
||||
// 正是「本线程退出」,不会自等(自等死锁只发生在线程 join 自己)。
|
||||
runOnUiThread {
|
||||
if (gen == sessionGeneration && handle == h) {
|
||||
cleanupSession(h, "连接已断开:$reason\n 点「重新连接」再次发起(验证码需重输)")
|
||||
}
|
||||
}
|
||||
}
|
||||
runOnUiThread { refreshStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
if (handle == 0L) return
|
||||
/**
|
||||
* 会话收尾的唯一入口(**只允许 UI 线程调用**)。顺序纪律不变(2b 真机验证过的模型):
|
||||
* 置 stopDecoding → join 解码线程 → destroy。代际由调用点的 `gen == sessionGeneration`
|
||||
* 复核负责;这里先把 handle 清零,第二次进入(surfaceDestroyed→disconnect 幂等路径)
|
||||
* 看到 0 直接跳过 destroy。
|
||||
*/
|
||||
private fun cleanupSession(h: Long, statusMsg: String) {
|
||||
stopDecoding = true
|
||||
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
|
||||
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。
|
||||
decodeThread?.join(6000)
|
||||
decodeThread = null
|
||||
val h = handle
|
||||
handle = 0L
|
||||
if (handle == h) handle = 0L
|
||||
remoteW = 0
|
||||
remoteH = 0
|
||||
lastFrames = 0
|
||||
lastRtt = 0
|
||||
lastFps = 0
|
||||
RustBridge.destroy(h)
|
||||
showConnectState("已断开")
|
||||
connecting = false
|
||||
connectButton.isEnabled = true
|
||||
connectButton.text = "重新连接"
|
||||
showConnectState(statusMsg)
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
val h = handle
|
||||
if (!connecting && h == 0L) return // 幂等:surfaceDestroyed→disconnect 二次进入
|
||||
sessionGeneration++ // 作废一切在途回调(connect 线程孤儿、断线提示)
|
||||
if (connecting) connecting = false
|
||||
if (h != 0L) {
|
||||
handle = 0L
|
||||
stopDecoding = true
|
||||
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
|
||||
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。
|
||||
decodeThread?.join(6000)
|
||||
decodeThread = null
|
||||
remoteW = 0
|
||||
remoteH = 0
|
||||
lastFrames = 0
|
||||
lastRtt = 0
|
||||
lastFps = 0
|
||||
RustBridge.destroy(h)
|
||||
}
|
||||
connectButton.isEnabled = true
|
||||
connectButton.text = "连接"
|
||||
showConnectState(if (h != 0L) "已断开" else "连接已取消")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@ -508,11 +654,13 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
/** 状态栏统一刷新:连接态 + 帧数/RTT + 最近输入诊断(logcat 被限流,这是唯一通道) */
|
||||
/** 状态栏统一刷新:连接态 + fps/帧数/RTT + 最近输入诊断(logcat 被限流,这是唯一通道) */
|
||||
@Volatile
|
||||
private var lastFrames = 0L
|
||||
@Volatile
|
||||
private var lastRtt = 0L
|
||||
@Volatile
|
||||
private var lastFps = 0L
|
||||
|
||||
private fun refreshStatus() {
|
||||
if (handle == 0L) {
|
||||
@ -520,7 +668,7 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
|
||||
return
|
||||
}
|
||||
statusView.text = buildString {
|
||||
append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH 帧=$lastFrames RTT=${lastRtt}ms\n")
|
||||
append("已连接 v$PROTO_LABEL ${remoteW}x$remoteH fps=$lastFps RTT=${lastRtt}ms 帧=$lastFrames\n")
|
||||
append("输入: $lastTouchDbg")
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,40 @@ import android.view.View
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import android.view.inputmethod.EditorInfo
|
||||
|
||||
/** 单次退格请求上限:防 IME 异常大值刷爆消息队列(与 agent 侧 WHEEL_MAX_CLICKS 同思路) */
|
||||
const val MAX_BACKSPACE = 64
|
||||
|
||||
/**
|
||||
* 退格决策(纯函数 [planBackspaces] 的输出)。类型刻意放在类外:
|
||||
* `RemoteInputConnection` 构造链到 `BaseInputConnection`(android.jar 桩,JVM 单测
|
||||
* 里抛 "not mocked"),决策本身无 Android 依赖——抽出来才能被 3a 的 JVM 单测覆盖(U7)。
|
||||
*/
|
||||
sealed class BackspaceAction {
|
||||
/** (0,0):IME 没有要删的东西 */
|
||||
object Noop : BackspaceAction()
|
||||
|
||||
/** 退格 [count] 次(已截断到 [MAX_BACKSPACE]) */
|
||||
data class Backspaces(val count: Int) : BackspaceAction()
|
||||
|
||||
/** `after>0` / 双向删除:本地无远端真值,不可执行(U3) */
|
||||
data class Unsupported(val before: Int, val after: Int) : BackspaceAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* `deleteSurroundingText(before, after)` 的决策逻辑,从 [RemoteInputConnection] 抽出。
|
||||
* 语义与原实现一致(3a 只提取不改动):
|
||||
* - `(0,0)` → Noop;
|
||||
* - `after>0` → Unsupported(forward-delete 形态本地无法执行,U3);
|
||||
* - `before>0 && after==0` → 退格 min(before, [MAX_BACKSPACE]) 次
|
||||
* (上限防 IME 异常大值刷爆消息队列,与 agent 侧 WHEEL_MAX_CLICKS 同思路)。
|
||||
*/
|
||||
fun planBackspaces(before: Int, after: Int, maxBackspace: Int = MAX_BACKSPACE): BackspaceAction =
|
||||
when {
|
||||
before == 0 && after == 0 -> BackspaceAction.Noop
|
||||
after != 0 -> BackspaceAction.Unsupported(before, after)
|
||||
else -> BackspaceAction.Backspaces(minOf(before, maxBackspace))
|
||||
}
|
||||
|
||||
/**
|
||||
* P1 IME 通道(loop-2b-plan §5,用户已裁决):**本地没有编辑缓冲,远端才是权威**。
|
||||
*
|
||||
@ -12,7 +46,8 @@ import android.view.inputmethod.EditorInfo
|
||||
* 1. `commitText` = 用户确认的整段文本 → 直接走协议 v3 `Text` 通道(布局无关、支持中文);
|
||||
* 2. `sendKeyEvent` = 硬键盘/控制键 → 既有扫描码通道(Enter/Backspace/方向键…);
|
||||
* 3. `deleteSurroundingText`:本地不知道远端内容,`before>0 && after==0`
|
||||
* 是 IME 退格最常见形态 → 发对应次数的 Backspace;其余形态记录后放弃(U3)。
|
||||
* 是 IME 退格最常见形态 → 发对应次数的 Backspace;其余形态记录后放弃(U3,
|
||||
* 决策逻辑抽到纯函数 [planBackspaces],3a 起可 JVM 单测)。
|
||||
*
|
||||
* composing(拼音未确认串)不显示到远端(U4):`setComposingText` 吞掉但返回 true,
|
||||
* 让 IME 维持本地组合状态,候选确认后仍会走 `commitText`。
|
||||
@ -66,25 +101,28 @@ class RemoteInputConnection(
|
||||
}
|
||||
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean =
|
||||
deleteBackspaces(beforeLength, afterLength)
|
||||
applyBackspaces(planBackspaces(beforeLength, afterLength))
|
||||
|
||||
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean =
|
||||
deleteBackspaces(beforeLength, afterLength)
|
||||
applyBackspaces(planBackspaces(beforeLength, afterLength))
|
||||
|
||||
private fun deleteBackspaces(before: Int, after: Int): Boolean {
|
||||
if (before == 0 && after == 0) return true
|
||||
if (after != 0) {
|
||||
/** 决策 → 注入。日志纪律:只报数量与形态,不报内容。 */
|
||||
private fun applyBackspaces(action: BackspaceAction): Boolean = when (action) {
|
||||
is BackspaceAction.Noop -> true
|
||||
is BackspaceAction.Unsupported -> {
|
||||
// U3:forward-delete 与「先删 after 再删 before」的形态本地无远端真值可依
|
||||
debug("delete($before,$after):after>0 形态未支持")
|
||||
return false
|
||||
debug("delete(${action.before},${action.after}):after>0 形态未支持")
|
||||
false
|
||||
}
|
||||
val bk = WinScancode.of(KeyEvent.KEYCODE_DEL) ?: return false
|
||||
repeat(before.coerceAtMost(MAX_BACKSPACE)) {
|
||||
sendKey(bk, true)
|
||||
sendKey(bk, false)
|
||||
is BackspaceAction.Backspaces -> {
|
||||
val bk = WinScancode.of(KeyEvent.KEYCODE_DEL) ?: return false
|
||||
repeat(action.count) {
|
||||
sendKey(bk, true)
|
||||
sendKey(bk, false)
|
||||
}
|
||||
debug("backspace x${action.count}")
|
||||
true
|
||||
}
|
||||
debug("backspace x$before")
|
||||
return true
|
||||
}
|
||||
|
||||
/** 吞掉 composing 但返回 true:IME 保持本地组合流程,候选确认仍会 commitText。 */
|
||||
@ -113,9 +151,4 @@ class RemoteInputConnection(
|
||||
debug("editorAction $actionCode 忽略")
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 单次退格请求上限:防 IME 异常大值刷爆消息队列(与 agent 侧 WHEEL_MAX_CLICKS 同思路) */
|
||||
private const val MAX_BACKSPACE = 64
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,28 +144,21 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
private fun midY(e: MotionEvent) =
|
||||
if (e.pointerCount >= 2) (e.getY(0) + e.getY(1)) / 2f else e.y
|
||||
|
||||
/** 诊断回调:把触摸情况显示到状态栏(本机 logcat 被系统限流,日志不可靠) */
|
||||
/**
|
||||
* 诊断回调:把触摸情况显示到状态栏(本机 logcat 被系统限流,日志不可靠)。
|
||||
*
|
||||
* 3a 降噪(V-A5):只显示**事件语义**(单击/拖拽/右键/滚轮),不再显示
|
||||
* 坐标与归一化值——那 20 列噪声挤满状态栏,且对操作者没有决策价值
|
||||
* (坐标类验证由 proof 脚本在 PC 侧读光标闭环,不经屏幕)。
|
||||
*/
|
||||
var onDebug: ((String) -> Unit)? = null
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val l = listener ?: run {
|
||||
onDebug?.invoke("touch: listener=null")
|
||||
onDebug?.invoke("触摸(未接线)")
|
||||
return false
|
||||
}
|
||||
if (width == 0 || height == 0) return false
|
||||
val act = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> "DOWN"
|
||||
MotionEvent.ACTION_MOVE -> "MOVE"
|
||||
MotionEvent.ACTION_UP -> "UP"
|
||||
MotionEvent.ACTION_POINTER_DOWN -> "P2DOWN"
|
||||
MotionEvent.ACTION_POINTER_UP -> "P2UP"
|
||||
MotionEvent.ACTION_CANCEL -> "CANCEL"
|
||||
else -> event.actionMasked.toString()
|
||||
}
|
||||
onDebug?.invoke(
|
||||
"$act view=(${event.x.toInt()},${event.y.toInt()}) " +
|
||||
"norm=(${normX(event.x)},${normY(event.y)}) ${width}x$height"
|
||||
)
|
||||
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
@ -175,6 +168,7 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
mode = Mode.TAP
|
||||
// 先把远端指针移到位,点击/拖拽都以此为基准
|
||||
l.sendMouseMove(normX(event.x), normY(event.y))
|
||||
onDebug?.invoke("按下")
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
@ -187,6 +181,7 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
twoFingerStartY = midY(event)
|
||||
lastX = midX(event); lastY = midY(event)
|
||||
twoFingerDownTime = System.currentTimeMillis()
|
||||
onDebug?.invoke("双指按下")
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> when (mode) {
|
||||
@ -195,6 +190,7 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
l.sendMouseButton(BTN_LEFT, true)
|
||||
lastX = event.x; lastY = event.y
|
||||
l.sendMouseMove(normX(event.x), normY(event.y))
|
||||
onDebug?.invoke("拖拽中")
|
||||
}
|
||||
|
||||
Mode.DRAG -> {
|
||||
@ -213,6 +209,7 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
if (notches != 0) {
|
||||
l.sendWheel(-notches * WHEEL_DELTA)
|
||||
wheelAccum -= notches * WHEEL_PX_PER_NOTCH
|
||||
onDebug?.invoke(if (notches > 0) "滚轮↓" else "滚轮↑")
|
||||
}
|
||||
lastX = cx; lastY = cy
|
||||
}
|
||||
@ -223,7 +220,7 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
MotionEvent.ACTION_POINTER_UP -> {
|
||||
// 双指之一抬起:先判定右键,再把剩余手指的移动视为已结束手势
|
||||
if (mode == Mode.TWO_FINGER) {
|
||||
maybeRightClick(l)
|
||||
if (maybeRightClick(l)) onDebug?.invoke("右键")
|
||||
mode = Mode.IDLE
|
||||
}
|
||||
}
|
||||
@ -235,9 +232,15 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
l.sendMouseButton(BTN_LEFT, true)
|
||||
l.sendMouseButton(BTN_LEFT, false)
|
||||
performClick()
|
||||
onDebug?.invoke("单击")
|
||||
}
|
||||
Mode.DRAG -> {
|
||||
l.sendMouseButton(BTN_LEFT, false)
|
||||
onDebug?.invoke("拖拽结束")
|
||||
}
|
||||
Mode.TWO_FINGER -> {
|
||||
if (maybeRightClick(l)) onDebug?.invoke("右键")
|
||||
}
|
||||
Mode.DRAG -> l.sendMouseButton(BTN_LEFT, false)
|
||||
Mode.TWO_FINGER -> maybeRightClick(l)
|
||||
Mode.IDLE -> {}
|
||||
}
|
||||
mode = Mode.IDLE
|
||||
@ -246,19 +249,21 @@ class RemoteSurfaceView(context: Context) : SurfaceView(context) {
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
|
||||
mode = Mode.IDLE
|
||||
onDebug?.invoke("手势取消")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 双指未移动且按住时间短 → 右键 */
|
||||
private fun maybeRightClick(l: Listener) {
|
||||
if (twoFingerMoved) return
|
||||
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return
|
||||
/** 双指未移动且按住时间短 → 右键。返回是否真的注入了右键(供诊断显示)。 */
|
||||
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))
|
||||
l.sendMouseButton(BTN_RIGHT, true)
|
||||
l.sendMouseButton(BTN_RIGHT, false)
|
||||
performClick()
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("ClickableViewAccessibility")
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
package com.verify.remote
|
||||
|
||||
/**
|
||||
* 连接失败:Rust 侧分类([kind],`ConnectErrorText.KIND_*`)+ 原始错误文本([detail])。
|
||||
* 界面第一行按分类给中文可执行提示,第二行显示 detail(排障用,见 3a 错误显示纪律)。
|
||||
*/
|
||||
class ConnectFailure(val kind: Int, val detail: String) : Exception(detail)
|
||||
|
||||
/**
|
||||
* viewer-core(Rust)的 JNI 声明。符号约定:Java_com_verify_remote_RustBridge_*,
|
||||
* 与 android-ffi/src/lib.rs 一一对应。
|
||||
@ -18,6 +24,13 @@ object RustBridge {
|
||||
|
||||
private external fun nativeConnect(host: String, port: Int, code: String): Long
|
||||
external fun nativeLastError(): String?
|
||||
|
||||
/**
|
||||
* 最近一次 nativeConnect 失败的业务分类(`ConnectErrorText.KIND_*` 编号)。
|
||||
* FFI 契约(android-ffi/src/lib.rs):**必须在 nativeLastError() 之前调用**——
|
||||
* 消息读走时槽位整体清空,颠倒顺序拿到 kind=0。
|
||||
*/
|
||||
external fun nativeLastErrorKind(): Int
|
||||
/** 返回 null = 会话结束;否则 [FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */
|
||||
external fun nativeNextFrame(handle: Long): ByteArray?
|
||||
external fun nativeStats(handle: Long): Long
|
||||
@ -35,11 +48,16 @@ object RustBridge {
|
||||
external fun nativeRequestKeyframe(handle: Long)
|
||||
external fun nativeDestroy(handle: Long)
|
||||
|
||||
/** 连接。失败抛 IllegalStateException(带 Rust 侧错误消息)。 */
|
||||
/**
|
||||
* 连接。失败抛 [ConnectFailure](分类 + Rust 侧原始消息)。
|
||||
* 取序遵守 FFI 契约:先 kind 后 message。
|
||||
*/
|
||||
fun connect(host: String, port: Int, code: String): Long {
|
||||
val h = nativeConnect(host, port, code)
|
||||
if (h == 0L) {
|
||||
throw IllegalStateException(nativeLastError() ?: "connect failed")
|
||||
val kind = nativeLastErrorKind()
|
||||
val msg = nativeLastError() ?: "connect failed"
|
||||
throw ConnectFailure(kind, msg)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
package com.verify.remote
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 3a/U7:连接错误编号→文案映射。
|
||||
* 编号契约的另一端是 Rust(`viewer_core::ConnectErrorKind::code`,
|
||||
* 由 `cargo test -p viewer-core ffi_error_codes_are_stable_and_unique` 锁死),
|
||||
* 这里锁 Kotlin 侧的完整覆盖与文案质量——两端一致才算契约成立。
|
||||
*/
|
||||
class ConnectErrorTextTest {
|
||||
|
||||
@Test
|
||||
fun every_known_kind_has_actionable_chinese_text() {
|
||||
val kinds = listOf(
|
||||
ConnectErrorText.KIND_RESOLVE,
|
||||
ConnectErrorText.KIND_TIMEOUT,
|
||||
ConnectErrorText.KIND_REFUSED,
|
||||
ConnectErrorText.KIND_AUTH,
|
||||
ConnectErrorText.KIND_VERSION,
|
||||
ConnectErrorText.KIND_OTHER,
|
||||
ConnectErrorText.KIND_FFI_SELF,
|
||||
)
|
||||
for (k in kinds) {
|
||||
val s = ConnectErrorText.summary(k)
|
||||
assertTrue("kind=$k 必须有文案", !s.isNullOrEmpty())
|
||||
// 项目纪律:用户可见输出中文(文案里至少含一个 CJK 字符)
|
||||
assertTrue("kind=$k 文案必须中文", s!!.any { c -> c.code in 0x4E00..0x9FFF })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun codes_match_ffi_contract() {
|
||||
// 期望值 = android-ffi / viewer-core 的 code() 表(改动必须三处同步)
|
||||
assertEquals(1, ConnectErrorText.KIND_RESOLVE)
|
||||
assertEquals(2, ConnectErrorText.KIND_TIMEOUT)
|
||||
assertEquals(3, ConnectErrorText.KIND_REFUSED)
|
||||
assertEquals(4, ConnectErrorText.KIND_AUTH)
|
||||
assertEquals(5, ConnectErrorText.KIND_VERSION)
|
||||
assertEquals(9, ConnectErrorText.KIND_OTHER)
|
||||
assertEquals(-1, ConnectErrorText.KIND_FFI_SELF)
|
||||
assertEquals(0, ConnectErrorText.KIND_NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun texts_are_distinct_per_failure_mode() {
|
||||
// 五种可区分失败不能给同一句文案(V-A2 的映射前提)
|
||||
val texts = listOf(
|
||||
ConnectErrorText.KIND_RESOLVE,
|
||||
ConnectErrorText.KIND_TIMEOUT,
|
||||
ConnectErrorText.KIND_REFUSED,
|
||||
ConnectErrorText.KIND_AUTH,
|
||||
ConnectErrorText.KIND_VERSION,
|
||||
).map { ConnectErrorText.summary(it)!! }
|
||||
assertEquals(texts.size, texts.distinct().size)
|
||||
for (t in texts) {
|
||||
assertNotEquals("连接失败", t) // 可分类的不得落兜底文案
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknown_kind_falls_back_without_claiming_a_cause() {
|
||||
// 编号漂移时不猜语义:兜底文案 + 带未知编号(R-8:宁可少说不可说错)
|
||||
assertNull(ConnectErrorText.summary(42))
|
||||
val line = ConnectErrorText.firstLine(42)
|
||||
assertTrue("实际: $line", line.contains("42"))
|
||||
assertTrue("实际: $line", line.startsWith("连接失败"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstLine_uses_summary_for_known_kinds() {
|
||||
for (k in listOf(1, 2, 3, 4, 5, 9, -1)) {
|
||||
assertEquals(ConnectErrorText.summary(k), ConnectErrorText.firstLine(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.verify.remote
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 3a/U7:退格决策纯函数。原逻辑在 `RemoteInputConnection.deleteBackspaces`
|
||||
* (构造链依赖 android.jar 桩,JVM 里不可测),3a 提取为 [planBackspaces] 后
|
||||
* 首次可单测。期望值对照 2b 真机行为(语义不变,见 RemoteInputConnection 注释)。
|
||||
*/
|
||||
class PlanBackspacesTest {
|
||||
|
||||
@Test
|
||||
fun zero_zero_is_noop() {
|
||||
assertEquals(BackspaceAction.Noop, planBackspaces(0, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun after_positive_is_unsupported_and_reports_both_counts() {
|
||||
// U3:forward-delete 形态本地无远端真值,必须显式 Unsupported(可见后果),
|
||||
// 不得静默当退格执行——before 值要随形态带出去供 debug 通道显示
|
||||
assertEquals(BackspaceAction.Unsupported(2, 1), planBackspaces(2, 1))
|
||||
assertEquals(BackspaceAction.Unsupported(0, 3), planBackspaces(0, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun before_only_maps_to_backspace_count() {
|
||||
// IME 退格最常见形态(R-9 实测)
|
||||
assertEquals(BackspaceAction.Backspaces(3), planBackspaces(3, 0))
|
||||
assertEquals(BackspaceAction.Backspaces(1), planBackspaces(1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun count_is_capped_at_limit() {
|
||||
// 防 IME 异常大值刷爆消息队列;截断语义必须发生在计划层(执行层无感知上限)
|
||||
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(64, 0))
|
||||
assertEquals(BackspaceAction.Backspaces(64), planBackspaces(999, 0))
|
||||
assertEquals(BackspaceAction.Backspaces(MAX_BACKSPACE), planBackspaces(1_000_000, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun MAX_BACKSPACE_constant_is_pinned() {
|
||||
// 与 RemoteInputConnection 的 companion 原值一致(提取时不得改数)
|
||||
assertEquals(64, MAX_BACKSPACE)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package com.verify.remote
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 3a/U7 首批 JVM 单测:扫描码映射表(此前只能真机点验)。
|
||||
*
|
||||
* `KeyEvent.KEYCODE_*` 是 Java 编译期常量(static final int),引用会被内联进
|
||||
* 测试字节码——JVM 单测不触发 android.jar 桩类加载,所以这里不需要 Robolectric。
|
||||
* 期望值来自项目内部权威源 `viewer/src/keymap.rs`(同 R-1:不信记忆,对照项目已有用法)。
|
||||
*/
|
||||
class WinScancodeTest {
|
||||
|
||||
@Test
|
||||
fun enter_is_make_0x1C_not_extended() {
|
||||
assertEquals(WinKey(0x1C, false), WinScancode.of(KeyEvent.KEYCODE_ENTER))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backspace_is_0x0E() {
|
||||
assertEquals(WinKey(0x0E, false), WinScancode.of(KeyEvent.KEYCODE_DEL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forward_delete_is_0x53_extended() {
|
||||
assertEquals(WinKey(0x53, true), WinScancode.of(KeyEvent.KEYCODE_FORWARD_DEL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun arrows_are_extended() {
|
||||
assertEquals(WinKey(0x48, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_UP))
|
||||
assertEquals(WinKey(0x50, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_DOWN))
|
||||
assertEquals(WinKey(0x4B, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_LEFT))
|
||||
assertEquals(WinKey(0x4D, true), WinScancode.of(KeyEvent.KEYCODE_DPAD_RIGHT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun right_shift_is_NOT_extended_with_its_own_scancode() {
|
||||
// 微软 Keyboard Input Overview:右 Shift 不是扩展键,它有独立扫描码 0x36
|
||||
// (2b 的 D4 偏差修正,回归锁死)
|
||||
assertEquals(WinKey(0x36, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_RIGHT))
|
||||
assertEquals(WinKey(0x2A, false), WinScancode.of(KeyEvent.KEYCODE_SHIFT_LEFT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun right_ctrl_and_alt_are_extended_shared_scancodes() {
|
||||
assertEquals(WinKey(0x1D, true), WinScancode.of(KeyEvent.KEYCODE_CTRL_RIGHT))
|
||||
assertEquals(WinKey(0x1D, false), WinScancode.of(KeyEvent.KEYCODE_CTRL_LEFT))
|
||||
assertEquals(WinKey(0x38, true), WinScancode.of(KeyEvent.KEYCODE_ALT_RIGHT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun letters_and_digits_match_keymap_rs() {
|
||||
// 抽查与 keymap.rs 交集的字母/数字(Set-1 make code)
|
||||
assertEquals(WinKey(0x1E, false), WinScancode.of(KeyEvent.KEYCODE_A))
|
||||
assertEquals(WinKey(0x2C, false), WinScancode.of(KeyEvent.KEYCODE_Z))
|
||||
assertEquals(WinKey(0x02, false), WinScancode.of(KeyEvent.KEYCODE_1))
|
||||
assertEquals(WinKey(0x0B, false), WinScancode.of(KeyEvent.KEYCODE_0))
|
||||
assertEquals(WinKey(0x39, false), WinScancode.of(KeyEvent.KEYCODE_SPACE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unmapped_and_unknown_keycodes_return_null() {
|
||||
// KEYCODE_UNKNOWN=0:软键盘可打印字符的形态,必须落 null 让调用方走 Text 通道,
|
||||
// 不能兜底成任意扫描码(2b D13 的根因之一)
|
||||
assertNull(WinScancode.of(KeyEvent.KEYCODE_UNKNOWN))
|
||||
assertNull(WinScancode.of(0))
|
||||
assertNull(WinScancode.of(-1))
|
||||
assertNull(WinScancode.of(9999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun f_keys_pinned_until_u6_physical_keyboard_verification() {
|
||||
// N5/U6:F1-F10 来自 Set-1 常识与 keymap.rs 交集,未在 Windows 逐一对账;
|
||||
// 这里锁「当前值不变」,防止无声漂移——真机接物理键盘验证后(U6)才允许改期望值
|
||||
assertEquals(WinKey(0x3B, false), WinScancode.of(KeyEvent.KEYCODE_F1))
|
||||
assertEquals(WinKey(0x44, false), WinScancode.of(KeyEvent.KEYCODE_F10))
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,6 @@
|
||||
//! 仅限局域网测试)。产品化前必须换成信令下发的证书指纹钉扎(`signaling.rs`
|
||||
//! 已有 `CertFingerprint`),该项登记在方案阶段 4。
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use common::{
|
||||
read_control, read_video_frame, write_control, ControlMsg, InputEvent, VideoFrameHeader,
|
||||
PROTO_VERSION,
|
||||
@ -30,6 +29,30 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// QUIC 握手单次尝试预算(第一拨):不可达/被拦路径的失败上限(阶段 3a U8)。
|
||||
/// 与被控端 `agent-core` 的 max_idle_timeout(10s) 同思路——超时策略必须显式、短、可见。
|
||||
pub const CONNECT_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 第二拨握手预算(重拨专用,8s > 第一拨)。
|
||||
/// 存在理由:agent 串行 accept,僵尸释放实测在「杀端后 ~10.2s + 残骸握手再排
|
||||
/// 5s 档」≈13s 窗口内;第二拨 8s 让握手段天然长于僵尸窗口,一次重拨接住。
|
||||
/// 真·不可达不受此影响——它由总预算封顶报 Timeout。
|
||||
pub const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(8);
|
||||
|
||||
/// 认证应答预算:握手成功后等 `AuthOk` 的上限。agent 串行 accept(R-5),
|
||||
/// 正常应答是毫秒级;11s 只是异常场景(agent 卡在旧会话收尾)的兜底。
|
||||
pub const AUTH_BUDGET: Duration = Duration::from_secs(11);
|
||||
|
||||
/// connect() 全程硬上限(含重拨)。14s = 5 + 8 两拨握手预算 + 1s 余量:
|
||||
/// 正常路径由**内层**各拨预算先触发(错误 detail 精确到段),外层只兜
|
||||
/// 「重试组合出无界等待」这一种失控。真不可达 → ≈13s 报 Timeout
|
||||
/// (2b 的 32s → 13s 且全程可取消,判据更新见 V-A1)。
|
||||
pub const CONNECT_TOTAL_BUDGET: Duration = Duration::from_secs(14);
|
||||
|
||||
/// 可重拨失败(Timeout/Refused)的最大尝试次数。Resolve/Auth/Version 是
|
||||
/// 确定性结果,重拨只会拖慢反馈,不重试。
|
||||
pub const MAX_CONNECT_ATTEMPTS: usize = 2;
|
||||
|
||||
/// 连接目标。当前只有局域网直连;设备码 + 打洞在后续阶段替换 `connect()` 内部实现。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PeerTarget {
|
||||
@ -62,6 +85,110 @@ impl ConnectConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接建立失败的分类(阶段 3a)。
|
||||
///
|
||||
/// 为什么分类在 Rust 侧做而不是 Kotlin:错误来源(DNS / QUIC 握手 / 认证 / 版本)
|
||||
/// 只有这一层看得全;Kotlin 只做 `code → 中文文案` 映射。
|
||||
/// [`ConnectErrorKind::code`] 是 FFI 编号(android-ffi `nativeLastErrorKind` 与 Kotlin
|
||||
/// `ConnectErrorText` 都按它映射),**改动必须三处同步**——FFI 只传整数不传 Rust 类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectErrorKind {
|
||||
/// 地址解析失败:host 写错或 DNS 不可达
|
||||
Resolve,
|
||||
/// 连接超时:被控端不在线、IP/端口不对、入站被拦
|
||||
Timeout,
|
||||
/// 被控端异常应答/拒绝:该端口上没有 QUIC 服务、握手被重置
|
||||
Refused,
|
||||
/// 认证失败:验证码错误
|
||||
Auth,
|
||||
/// 协议版本不一致:两端必须重编到同一版本
|
||||
Version,
|
||||
/// 未分类。**detail 原文必须展示**(第二行),不得静默归进某类文案
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ConnectErrorKind {
|
||||
/// FFI 稳定编号。新增项只追加,不复用旧编号(Kotlin `else ->` 兜住未知码)。
|
||||
pub fn code(self) -> i32 {
|
||||
match self {
|
||||
ConnectErrorKind::Resolve => 1,
|
||||
ConnectErrorKind::Timeout => 2,
|
||||
ConnectErrorKind::Refused => 3,
|
||||
ConnectErrorKind::Auth => 4,
|
||||
ConnectErrorKind::Version => 5,
|
||||
ConnectErrorKind::Other => 9,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接失败 = 分类 + 原始错误文本。
|
||||
#[derive(Debug)]
|
||||
pub struct ConnectError {
|
||||
pub kind: ConnectErrorKind,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl ConnectError {
|
||||
fn new(kind: ConnectErrorKind, detail: impl Into<String>) -> Self {
|
||||
Self { kind, detail: detail.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConnectError {}
|
||||
|
||||
/// 未分类错误的统一包装:保留原文(Other 的 detail 会显示到界面第二行,不静默)。
|
||||
fn other_err(e: impl std::fmt::Display) -> ConnectError {
|
||||
ConnectError::new(ConnectErrorKind::Other, e.to_string())
|
||||
}
|
||||
|
||||
/// 认证失败消息 → 分类。判据是 agent 侧的稳定前缀(`agent/src/main.rs` 与
|
||||
/// `agent-core/src/lib.rs` 都以 `protocol version mismatch` 开头);
|
||||
/// 命中不了的一律 `Auth`——宁可提示「验证码错误」也不把内部错误文案当分类依据。
|
||||
fn classify_auth_fail(msg: &str) -> ConnectErrorKind {
|
||||
if msg.contains("protocol version mismatch") {
|
||||
ConnectErrorKind::Version
|
||||
} else {
|
||||
ConnectErrorKind::Auth
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步 connect() 调用(本地配置面)的分类:全是 Other(endpoint 停摆/地址非法等),
|
||||
/// 保留原文。单列函数是为了让分类面在测试里可枚举、改动可见。
|
||||
fn classify_connect_error(_e: &quinn::ConnectError) -> ConnectErrorKind {
|
||||
ConnectErrorKind::Other
|
||||
}
|
||||
|
||||
/// 哪些失败值得重拨(3a 方案①)。
|
||||
/// - Timeout/Refused:agent 串行 accept 的排队/残骸窗口是**暂时**的
|
||||
/// (实测僵尸释放 ~10.2s、残骸阻塞握手 5~8.5s),第二拨能接住;
|
||||
/// - Resolve/Auth/Version:确定性结果,重拨只拖慢反馈、不改变答案;
|
||||
/// - Other:语义不明(含钉扎未实现、首消息异常),不猜、不重试。
|
||||
fn is_retryable(kind: ConnectErrorKind) -> bool {
|
||||
matches!(kind, ConnectErrorKind::Timeout | ConnectErrorKind::Refused)
|
||||
}
|
||||
|
||||
/// 握手/连接期错误 → 分类。逐个变体对照 quinn 0.11.12 re-export 的
|
||||
/// `quinn::ConnectionError`(quinn-proto 0.11.19 `connection/mod.rs:3906`,本会话实读)。
|
||||
fn classify_connection_error(e: &quinn::ConnectionError) -> ConnectErrorKind {
|
||||
use quinn::ConnectionError as E;
|
||||
match e {
|
||||
E::TimedOut => ConnectErrorKind::Timeout,
|
||||
// 端口上没有 QUIC 服务(回收包/非法头)、被重置、握手后被关:
|
||||
// 用户语境统一是「对端不应答」→ Refused
|
||||
E::TransportError(_) | E::Reset | E::ConnectionClosed(_) | E::ApplicationClosed(_) => {
|
||||
ConnectErrorKind::Refused
|
||||
}
|
||||
// QUIC 传输版本协商失败 ≠ 应用协议版本不一致,不要误导成 Version
|
||||
E::VersionMismatch | E::LocallyClosed | E::CidsExhausted => ConnectErrorKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 交付给消费方的一帧视频。字段语义与 `common::VideoFrameHeader` 一致,
|
||||
/// payload 为标准 Annex-B H.264(可直接喂 MediaCodec)。
|
||||
#[derive(Debug, Clone)]
|
||||
@ -163,7 +290,66 @@ pub struct Session {
|
||||
|
||||
impl Session {
|
||||
/// 建立连接并完成认证握手 + 首帧关键帧请求。
|
||||
pub async fn connect(cfg: ConnectConfig) -> Result<Self> {
|
||||
///
|
||||
/// **短预算 × 有限重拨**(3a/U8,2026-10-31 用户裁决方案①;演进证据链见
|
||||
/// loop-3-plan 偏差 D2/D3:单次 5s 预算在 agent 串行 accept 架构下自相矛盾——
|
||||
/// 不可达要快失败、僵尸排队要耐心等,同一数字满足不了;拆握手/认证两段也不够,
|
||||
/// 实测阻塞点在握手本身):
|
||||
/// - 每次尝试:握手第一拨 [`CONNECT_BUDGET`](5s)、重拨拨
|
||||
/// [`CONNECT_RETRY_BUDGET`](8s)、认证 [`AUTH_BUDGET`](11s);
|
||||
/// - [`is_retryable`] 的失败(Timeout/Refused)**自动重拨**,共
|
||||
/// [`MAX_CONNECT_ATTEMPTS`](2) 次。存在理由(2026-10-31 两组对照实验):
|
||||
/// agent 串行 accept 期间不泵 socket,新握手被残骸/僵尸阻塞实测 5~8.5s
|
||||
/// (杀会话后必现、干净连发 8 次亦有 1 次);第一拨 5s 被切是预期成本,
|
||||
/// 第二拨的窗口天然落在释放点之后。包级机制未逐行核实(R-15),此处只
|
||||
/// 陈述实测:**第一拨 5s 超时 → 第二拨成功**(kill 场景 total ≈5-11s 内接住)。
|
||||
/// - 全程 [`CONNECT_TOTAL_BUDGET`](13s) 硬上限 = 5+8 完整两拨,
|
||||
/// 防重试组合出无界等待;
|
||||
/// - 真不可达 ≈13s 报 Timeout(修复前 32s),**全程 UI 可取消**——
|
||||
/// U8 判据形态由此更新(plan V-A1)。
|
||||
///
|
||||
/// 已知边界:DNS 解析走同步 `to_socket_addrs`,current-thread runtime 里阻塞调用
|
||||
/// 不能被 timeout 打断;本机实测解析失败是亚秒级。极端 DNS 挂起属阶段 4 网络治理面。
|
||||
pub async fn connect(cfg: ConnectConfig) -> Result<Self, ConnectError> {
|
||||
let attempts = async {
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
attempt += 1;
|
||||
// 第一拨短握手(快速判不可达),重拨拨长握手(接住僵尸释放)
|
||||
let handshake = if attempt == 1 {
|
||||
CONNECT_BUDGET
|
||||
} else {
|
||||
CONNECT_RETRY_BUDGET
|
||||
};
|
||||
match Self::connect_once(cfg.clone(), handshake).await {
|
||||
Ok(s) => return Ok(s),
|
||||
Err(e) => {
|
||||
if attempt >= MAX_CONNECT_ATTEMPTS || !is_retryable(e.kind) {
|
||||
return Err(e);
|
||||
}
|
||||
log(&format!(
|
||||
"connect 第 {attempt}/{MAX_CONNECT_ATTEMPTS} 拨失败: {}(重拨)",
|
||||
e.detail
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// 外层硬上限。Timeout::poll 先轮询内层——已成功返回的会话不会被
|
||||
// 「刚好到点」的外层吞掉(D2 的教训:预算必须给合法路径留出完成空间)
|
||||
match tokio::time::timeout(CONNECT_TOTAL_BUDGET, attempts).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(ConnectError::new(
|
||||
ConnectErrorKind::Timeout,
|
||||
format!(
|
||||
"connect exceeded {}s total budget within {MAX_CONNECT_ATTEMPTS} attempts",
|
||||
CONNECT_TOTAL_BUDGET.as_secs()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_once(cfg: ConnectConfig, handshake_budget: Duration) -> Result<Self, ConnectError> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.ok();
|
||||
@ -175,11 +361,14 @@ impl Session {
|
||||
.with_no_client_auth()
|
||||
} else {
|
||||
// 证书钉扎尚未接入(方案阶段 4)。这里明确报错而不是静默放行。
|
||||
bail!("insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)");
|
||||
return Err(ConnectError::new(
|
||||
ConnectErrorKind::Other,
|
||||
"insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)",
|
||||
));
|
||||
};
|
||||
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls)
|
||||
.try_into()
|
||||
.context("build QUIC client config")?;
|
||||
.map_err(other_err)?;
|
||||
|
||||
// 接收窗口放大:视频下行大流量,quinn 默认 512KB 流窗口会卡吞吐
|
||||
// (与桌面 viewer / agent 的实测调优一致)
|
||||
@ -189,21 +378,54 @@ impl Session {
|
||||
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
|
||||
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
|
||||
let mut endpoint =
|
||||
quinn::Endpoint::client("0.0.0.0:0".parse().map_err(other_err)?).map_err(other_err)?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
|
||||
let PeerTarget::Direct { host, port } = &cfg.target;
|
||||
let addr = (host.as_str(), *port)
|
||||
.to_socket_addrs()?
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| {
|
||||
ConnectError::new(
|
||||
ConnectErrorKind::Resolve,
|
||||
format!("resolve {host}:{port}: {e}"),
|
||||
)
|
||||
})?
|
||||
.next()
|
||||
.with_context(|| format!("resolve {host}:{port}"))?;
|
||||
let conn = endpoint
|
||||
.connect(addr, "localhost")?
|
||||
.await
|
||||
.with_context(|| format!("QUIC connect {addr}"))?;
|
||||
.ok_or_else(|| {
|
||||
ConnectError::new(
|
||||
ConnectErrorKind::Resolve,
|
||||
format!("resolve {host}:{port}: no record"),
|
||||
)
|
||||
})?;
|
||||
let conn = {
|
||||
let connect_fut = endpoint.connect(addr, "localhost").map_err(|e| {
|
||||
ConnectError::new(classify_connect_error(&e), e.to_string())
|
||||
})?;
|
||||
// 单次尝试的握手预算(第一拨 5s、重拨拨 8s,见 connect() 注释)。
|
||||
// 超时归类 Timeout(可重拨);真不可达由重拨打满总预算后报出。
|
||||
match tokio::time::timeout(handshake_budget, connect_fut).await {
|
||||
Ok(Ok(c)) => c,
|
||||
Ok(Err(e)) => {
|
||||
return Err(ConnectError::new(
|
||||
classify_connection_error(&e),
|
||||
e.to_string(),
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(ConnectError::new(
|
||||
ConnectErrorKind::Timeout,
|
||||
format!(
|
||||
"QUIC handshake exceeded {}s budget",
|
||||
handshake_budget.as_secs()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 流 1:认证 + 视频下行 ----
|
||||
let (mut v_send, mut v_recv) = conn.open_bi().await?;
|
||||
let (mut v_send, mut v_recv) = conn.open_bi().await.map_err(other_err)?;
|
||||
write_control(
|
||||
&mut v_send,
|
||||
&ControlMsg::Auth {
|
||||
@ -212,30 +434,59 @@ impl Session {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("send Auth")?;
|
||||
.map_err(other_err)?;
|
||||
let desktop: (u32, u32);
|
||||
match read_control(&mut v_recv).await.context("read AuthOk")? {
|
||||
// 认证应答预算 11s:agent 串行 accept,僵尸会话最长 ~10s(R-5)才轮到本请求。
|
||||
// 这一段超时的语义是「连上了但对端一直不应答」——同样归类 Timeout,detail 说清。
|
||||
let first_msg = match tokio::time::timeout(AUTH_BUDGET, read_control(&mut v_recv)).await {
|
||||
Ok(r) => r.map_err(other_err)?,
|
||||
Err(_) => {
|
||||
return Err(ConnectError::new(
|
||||
ConnectErrorKind::Timeout,
|
||||
format!(
|
||||
"connected but auth reply exceeded {}s (agent busy?)",
|
||||
AUTH_BUDGET.as_secs()
|
||||
),
|
||||
))
|
||||
}
|
||||
};
|
||||
match first_msg {
|
||||
ControlMsg::AuthOk {
|
||||
width,
|
||||
height,
|
||||
proto_version,
|
||||
} => {
|
||||
if proto_version != PROTO_VERSION {
|
||||
bail!(
|
||||
"protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}"
|
||||
);
|
||||
return Err(ConnectError::new(
|
||||
ConnectErrorKind::Version,
|
||||
format!(
|
||||
"protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}"
|
||||
),
|
||||
));
|
||||
}
|
||||
desktop = (width, height);
|
||||
log(&format!("auth ok, remote desktop {width}x{height}"));
|
||||
}
|
||||
ControlMsg::AuthFail(e) => bail!("auth failed: {e}"),
|
||||
m => bail!("unexpected first message: {m:?}"),
|
||||
ControlMsg::AuthFail(e) => {
|
||||
return Err(ConnectError::new(
|
||||
classify_auth_fail(&e),
|
||||
format!("auth failed: {e}"),
|
||||
))
|
||||
}
|
||||
m => {
|
||||
return Err(ConnectError::new(
|
||||
ConnectErrorKind::Other,
|
||||
format!("unexpected first message: {m:?}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 流 2:输入 + 控制 + Ping/Pong ----
|
||||
let (mut ctl_send, mut ctl_recv) = conn.open_bi().await?;
|
||||
let (mut ctl_send, mut ctl_recv) = conn.open_bi().await.map_err(other_err)?;
|
||||
// 入会即请求 IDR:从 GOP 中间接入时裸 P 帧解不出来(桌面端同款教训)
|
||||
write_control(&mut ctl_send, &ControlMsg::KeyFrameReq).await?;
|
||||
write_control(&mut ctl_send, &ControlMsg::KeyFrameReq)
|
||||
.await
|
||||
.map_err(other_err)?;
|
||||
|
||||
let stats = Arc::new(std::sync::Mutex::new(SessionStats::default()));
|
||||
|
||||
@ -447,8 +698,138 @@ mod tests {
|
||||
insecure_skip_verify: false,
|
||||
};
|
||||
match Session::connect(cfg).await {
|
||||
Err(e) => assert!(e.to_string().contains("证书钉扎"), "实际错误: {e}"),
|
||||
Err(e) => {
|
||||
assert_eq!(e.kind, ConnectErrorKind::Other);
|
||||
assert!(e.detail.contains("证书钉扎"), "实际错误: {e}");
|
||||
}
|
||||
Ok(_) => panic!("insecure_skip_verify=false 必须明确报错,不得静默放行"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_fail_classification_uses_agent_message_prefix() {
|
||||
// 判据来自 agent 侧的真实文案(agent/src/main.rs:111),不是自造样本
|
||||
assert_eq!(
|
||||
classify_auth_fail("protocol version mismatch: client=2, server=3"),
|
||||
ConnectErrorKind::Version
|
||||
);
|
||||
assert_eq!(classify_auth_fail("bad code"), ConnectErrorKind::Auth);
|
||||
// 未知形态宁可归 Auth 也不静默
|
||||
assert_eq!(
|
||||
classify_auth_fail("expected Auth, got Ping(0)"),
|
||||
ConnectErrorKind::Auth
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffi_error_codes_are_stable_and_unique() {
|
||||
// FFI 编号是 Kotlin 映射表的契约面:改动=公共 API 变更。
|
||||
// 期望值写死(R-8:判据是等于期望值,不是"没重复")。
|
||||
let expect = [
|
||||
(ConnectErrorKind::Resolve, 1),
|
||||
(ConnectErrorKind::Timeout, 2),
|
||||
(ConnectErrorKind::Refused, 3),
|
||||
(ConnectErrorKind::Auth, 4),
|
||||
(ConnectErrorKind::Version, 5),
|
||||
(ConnectErrorKind::Other, 9),
|
||||
];
|
||||
for (kind, code) in expect {
|
||||
assert_eq!(kind.code(), code);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unresolvable_host_is_classified_as_resolve() {
|
||||
// 本机环境事实(2026-10-31 实测):这台办公机的 DNS 拦截层(Meta/198.18.0.1)
|
||||
// 把 `.invalid` 直接**解析成功**并指向 198.18.0.x 假地址——RFC 6761 的
|
||||
// NXDOMAIN 保证在这条链路上不成立,`.invalid` 样本会走成 Timeout。
|
||||
// 含空格的 name 在任何 resolver 里都是非法输入、快速失败(实测 26ms),
|
||||
// 判据不依赖本机 DNS 行为。
|
||||
let cfg = ConnectConfig::direct("a b", 47800, "t");
|
||||
let e = match Session::connect(cfg).await {
|
||||
Ok(_) => panic!("非法主机名必须解析失败"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert_eq!(e.kind, ConnectErrorKind::Resolve, "实际: {e:?}");
|
||||
assert!(e.detail.contains("a b"), "detail 必须含原输入: {e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_peer_redials_then_reports_timeout() {
|
||||
// TEST-NET-1(RFC 5737):无可达主机 → 每拨握手都无应答。
|
||||
// 方案①(用户裁决):第一拨 5s 超时 → 重拨 → 第二拨 8s 超时 → 报 Timeout。
|
||||
// 期望落点 [13s-1s, 14s+2s):
|
||||
// - 下界 ≈ 两拨握手预算之和,证明**第二拨真的跑完**(重拨丢失就是回归);
|
||||
// - 上界证明总预算封顶(不会滚向第三拨)。
|
||||
// 修复前(quinn 默认)这里要 32s;U8 判据形态更新为「≤13s 且全程可取消」。
|
||||
let cfg = ConnectConfig::direct("192.0.2.1", 47800, "t");
|
||||
let t0 = Instant::now();
|
||||
let e = match Session::connect(cfg).await {
|
||||
Ok(_) => panic!("TEST-NET-1 无主机,必须失败"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let elapsed = t0.elapsed();
|
||||
assert_eq!(e.kind, ConnectErrorKind::Timeout, "实际: {e:?}");
|
||||
let two_dials = CONNECT_BUDGET + CONNECT_RETRY_BUDGET;
|
||||
assert!(
|
||||
elapsed >= two_dials - Duration::from_secs(1)
|
||||
&& elapsed < CONNECT_TOTAL_BUDGET + Duration::from_secs(2),
|
||||
"重拨后的等待 {elapsed:?} 不在 [{two_dials:?}-1s, {CONNECT_TOTAL_BUDGET:?}+2s)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_constants_match_redial_design() {
|
||||
// 方案①常量关系固化(2026-10-31 用户裁决):
|
||||
// - 第一拨握手 5s:不可达的快速探测
|
||||
// - 第二拨握手 8s:第二拨起点在 ~5s,需盖住实测僵尸释放点 ~10.2s
|
||||
// (R-5 idle 超时)+ 余量——比第一拨长是这个物理原因,不是随手加大
|
||||
// - 总预算 14s:两拨之和 + 1s,让内层各拨 Timeout 先触发(错误 detail
|
||||
// 精确到段),外层只封顶失控组合
|
||||
// - 认证 11s:> 僵尸释放 10.2s(实测),握手成功后排队不被压断
|
||||
assert_eq!(CONNECT_BUDGET, Duration::from_secs(5));
|
||||
assert_eq!(CONNECT_RETRY_BUDGET, Duration::from_secs(8));
|
||||
assert_eq!(AUTH_BUDGET, Duration::from_secs(11));
|
||||
assert_eq!(CONNECT_TOTAL_BUDGET, Duration::from_secs(14));
|
||||
assert_eq!(MAX_CONNECT_ATTEMPTS, 2);
|
||||
assert!(CONNECT_RETRY_BUDGET > CONNECT_BUDGET, "第二拨必须更长才盖得住释放点");
|
||||
assert!(CONNECT_TOTAL_BUDGET > CONNECT_BUDGET + CONNECT_RETRY_BUDGET);
|
||||
assert!(AUTH_BUDGET > Duration::from_secs(10));
|
||||
// 重拨名单是策略面:只有暂时性失败可重试,改动必须过这条测试
|
||||
assert!(is_retryable(ConnectErrorKind::Timeout));
|
||||
assert!(is_retryable(ConnectErrorKind::Refused));
|
||||
for k in [
|
||||
ConnectErrorKind::Resolve,
|
||||
ConnectErrorKind::Auth,
|
||||
ConnectErrorKind::Version,
|
||||
ConnectErrorKind::Other,
|
||||
] {
|
||||
assert!(!is_retryable(k), "{k:?} 是确定性失败,不得重拨");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_error_variants_map_to_declared_kinds() {
|
||||
// 只测**可安全构造**的单元变体(unit variants):TransportError /
|
||||
// ConnectionClosed / ApplicationClose 的字段是 `bytes::Bytes`,测试构造
|
||||
// 需要把 bytes 提为直接依赖——为分类表测试引依赖不值当(偏差 D-R1)。
|
||||
// 这三个结构变体的映射由 match 的穷尽性 + 上面的注释钉住;
|
||||
// 真实归类靠 V-A2 的端到端(端口上跑非 QUIC 服务)覆盖。
|
||||
assert_eq!(
|
||||
classify_connection_error(&quinn::ConnectionError::TimedOut),
|
||||
ConnectErrorKind::Timeout
|
||||
);
|
||||
assert_eq!(
|
||||
classify_connection_error(&quinn::ConnectionError::Reset),
|
||||
ConnectErrorKind::Refused
|
||||
);
|
||||
assert_eq!(
|
||||
classify_connection_error(&quinn::ConnectionError::VersionMismatch),
|
||||
ConnectErrorKind::Other
|
||||
);
|
||||
assert_eq!(
|
||||
classify_connection_error(&quinn::ConnectionError::LocallyClosed),
|
||||
ConnectErrorKind::Other
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user