From 718b66617f953b90e3adbdb138f8343d351a9404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Sat, 31 Oct 2026 13:30:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20=E8=BF=9E=E6=8E=A5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA=20+=20=E5=8F=96=E6=B6=88/=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E5=9B=9E=E8=A1=A8=E5=8D=95=20+=20=E9=94=99=E8=AF=AF=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=98=A0=E5=B0=84=20+=20HUD=20fps=EF=BC=883a=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConnectFailure(kind,detail) + ConnectErrorText:编号→中文可执行提示,原始英文第二行 - sessionGeneration 代际:取消/断开/退出作废在途回调;等待中按钮变「取消」即时回表单 - 断线可见:取帧 null 回表单显示原因 + 「重新连接」(修「冻结在已连接」);destroy 只在 UI 线程 - BACK 语义补连接中:取消而非关 Activity(修悬挂会话) - 键盘按钮可见时收起(补唯一收起入口) - HUD 1s 窗口 fps;触摸/按键诊断去坐标去内容(V-A5,proof 不依赖这些字符串) - RemoteInputConnection 退格决策抽纯函数 planBackspaces(为 JVM 单测,语义不变) --- .../com/verify/remote/ConnectErrorText.kt | 38 ++++ .../kotlin/com/verify/remote/MainActivity.kt | 210 +++++++++++++++--- .../verify/remote/RemoteInputConnection.kt | 71 ++++-- .../com/verify/remote/RemoteSurfaceView.kt | 49 ++-- .../kotlin/com/verify/remote/RustBridge.kt | 22 +- 5 files changed, 316 insertions(+), 74 deletions(-) create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/ConnectErrorText.kt diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/ConnectErrorText.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/ConnectErrorText.kt new file mode 100644 index 0000000..c626469 --- /dev/null +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/ConnectErrorText.kt @@ -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)" +} diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt index be060ca..ac15c7a 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/MainActivity.kt @@ -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") } } diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt index db65cd3..fb50dab 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteInputConnection.kt @@ -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 - } } diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt index 3a9a38d..be5f7f7 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RemoteSurfaceView.kt @@ -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") diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt index 3202fcf..ae524f8 100644 --- a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/RustBridge.kt @@ -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 }