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:
parent
f28db050fb
commit
718b66617f
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user