feat(android): 连接状态机 + 取消/断线回表单 + 错误中文映射 + HUD fps(3a)

- ConnectFailure(kind,detail) + ConnectErrorText:编号→中文可执行提示,原始英文第二行
- sessionGeneration 代际:取消/断开/退出作废在途回调;等待中按钮变「取消」即时回表单
- 断线可见:取帧 null 回表单显示原因 + 「重新连接」(修「冻结在已连接」);destroy 只在 UI 线程
- BACK 语义补连接中:取消而非关 Activity(修悬挂会话)
- 键盘按钮可见时收起(补唯一收起入口)
- HUD 1s 窗口 fps;触摸/按键诊断去坐标去内容(V-A5,proof 不依赖这些字符串)
- RemoteInputConnection 退格决策抽纯函数 planBackspaces(为 JVM 单测,语义不变)
This commit is contained in:
郑博文 2026-10-31 13:30:07 +08:00
parent f28db050fb
commit 718b66617f
5 changed files with 316 additions and 74 deletions

View File

@ -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)"
}

View File

@ -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")
}
}

View File

@ -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
}
}

View File

@ -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")

View File

@ -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
}