From 9a6f6962d2650ec18bf9280a979b96ccb989e8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Mon, 2 Nov 2026 11:05:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(android):=20v5=20=E5=85=89=E6=A0=87?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=B1=82=EF=BC=88=E6=8B=86=E5=8C=85=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20FrameCodec=20+=20CursorMarker=20+=20Overlay=20+=20?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4b 手机端「看得见远端鼠标」的实现(协议 v5 的消费端): - RustBridge:Frame 增 cursor: Pair?(可空 Pair 忠实映射 Option<(i16,i16)>, 杜绝「x 有 y 无」非法态);拆包逻辑连同字节 reader 迁到顶层 FrameCodec—— RustBridge object 带 init{loadLibrary},测试里不可达,抽成无 native 的顶层 object 才能把跨端偏移放进 JVM 单测(核过 Rust 一端≠核过 Kotlin 另一端) - CursorMarker.mapToView:桌面坐标→视图坐标等比映射(letterbox 下线性等比, 与 RelPointer 同一套比例);null/尺寸未就绪/越界一律返回 null(不画), 绝不用 (0,0) 当缺省——那会把「无光标」和「光标在左上角」抹平成一样 - CursorOverlayView:SurfaceView 的上层兄弟视图(surface 合成在窗口之下, 同视图无法自叠加);双描边圆环+青色中心点(兼作 proof V-D1 的颜色特征码, UI 层绘制不经 H.264,screencap 里颜色精确);volatile + postInvalidate 跨线程; 不消费触摸(覆盖整个 stage,若吃事件会同时废掉远端操作和黑边唤出) - MainActivity:解码循环每帧喂 cursorOverlay.updateCursor(f.cursor)(放解码前, 光标是帧头字段与解码成败无关);cleanupSession 与 disconnect 两条收尾路径同改 清标记(R-5 同源,旧会话光标留着会冒充新会话) assembleDebug BUILD SUCCESSFUL(Canvas API 编译过);JVM 60 passed。 --- .../kotlin/com/verify/remote/CursorMarker.kt | 50 ++++++++++ .../com/verify/remote/CursorOverlayView.kt | 95 +++++++++++++++++++ .../kotlin/com/verify/remote/MainActivity.kt | 25 +++++ .../kotlin/com/verify/remote/RustBridge.kt | 62 +++++++++--- 4 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorMarker.kt create mode 100644 apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorOverlayView.kt diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorMarker.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorMarker.kt new file mode 100644 index 0000000..589139d --- /dev/null +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorMarker.kt @@ -0,0 +1,50 @@ +package com.verify.remote + +/** + * 光标标记的**纯逻辑**:把协议帧头里的远端光标坐标(采集画面内物理像素)映射到 + * 手机视图上的绘制位置。抽成纯 Kotlin 是为了能被 JVM 单测钉死(V-D1 判据依赖它)。 + * + * 为什么映射就是简单的等比缩放:[RemoteSurfaceView] 是 letterbox——视图宽高比恒等于 + * 远端桌面宽高比(`videoAspectRatio = remoteW / remoteH`),且视图左上角对应桌面 (0,0)。 + * 于是「视图内像素 ↔ 桌面像素」是一个线性等比,与 [RelPointer.toDesktopDelta] 用的是 + * **同一套比例**(那里是反向:视图像素增量→桌面像素增量)。 + * + * 这里的边界校验**不是**对被控端 `fold_cursor` 的重复防御:那条在 Windows 被控端, + * 而本协议把对端字节一律按**不可信输入**处理(见 viewer-core 纪律、[RustBridge.nextFrame] 的长度闸)。 + * 跨信任边界的输入必须自证——尤其 Linux 后端当前恒发 None,未来实装时若折叠写错, + * 这里不兜住就会把标记画到画面外。 + */ +object CursorMarker { + + /** 映射后的绘制点(视图内浮点像素)。绘制侧([CursorOverlayView])按此画圆环中心。 */ + data class OnView(val x: Float, val y: Float) + + /** + * @param desktopW/desktopH 远端采集画面尺寸(物理像素,来自 AuthOk/desktopSize) + * @param cursor 帧头光标坐标(桌面物理像素),null = 隐藏/离屏/未实现 + * @param viewW/viewH SurfaceView 当前测量尺寸(像素) + * @return 视图内绘制点;任一维度非法或坐标越界 → null(不画标记) + * + * null 的所有分支都必须走「不画」而不是「画在 (0,0)」:(0,0) 是一个**合法**的光标位置 + * (左上角),把它当成默认值会让「没有光标」和「光标在左上角」看起来一样—— + * 这正是协议用 Option 而不用 0 的原因,Kotlin 侧不能把这层区分抹平。 + */ + fun mapToView( + desktopW: Int, + desktopH: Int, + cursor: Pair?, + viewW: Int, + viewH: Int, + ): OnView? { + if (cursor == null) return null + // 尺寸未就绪(视图尚未测量 / desktopSize 还没取到):不画,而不是按 0 除 + if (desktopW <= 0 || desktopH <= 0 || viewW <= 0 || viewH <= 0) return null + val (cx, cy) = cursor + // 采集画面 [0,w)×[0,h) 之外不画(越界 = 后端折叠漏网或被篡改) + if (cx < 0 || cy < 0 || cx >= desktopW || cy >= desktopH) return null + return OnView( + x = cx.toFloat() * viewW.toFloat() / desktopW.toFloat(), + y = cy.toFloat() * viewH.toFloat() / desktopH.toFloat(), + ) + } +} diff --git a/apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorOverlayView.kt b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorOverlayView.kt new file mode 100644 index 0000000..bf89859 --- /dev/null +++ b/apps/android-viewer/app/src/main/kotlin/com/verify/remote/CursorOverlayView.kt @@ -0,0 +1,95 @@ +package com.verify.remote + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.View + +/** + * 远端光标标记层(4b/Q1=A、Q2=A、Q6=A):画在 [RemoteSurfaceView] **之上**的透明兄弟视图。 + * + * 为什么是兄弟视图而不是 SurfaceView 自己画:SurfaceView 的内容合成在窗口之下 + * (窗口在其位置打透明洞),同一视图内无法给自己的 surface 叠加绘制。兄弟 View 盖在 + * 洞上方是标准做法(视频播放器控件同理)。[RemoteSurfaceView] 保持纯采集/触摸职责不变。 + * + * 坐标换算全部委托 [CursorMarker.mapToView](纯逻辑、JVM 单测钉死);本类只负责: + * 接收解码线程写来的光标 → invalidate → 在 UI 线程把「桌面坐标」画成「屏幕坐标」。 + * 锚点几何(surfaceView 的 x/y/width/height)在 onDraw 现取——旋转/重布局后自动跟新, + * 无需订阅布局回调。 + * + * 线程纪律:[updateCursor] 由**解码线程**每帧调用(与 stopDecoding 同款的跨线程写入), + * 字段 volatile + [postInvalidate](安卓文档指定的跨线程失效 API)。绘制侧只在 UI 线程读。 + * + * 触摸:本视图**不消费任何触摸**(不可点击、不可聚焦)——它覆盖整个 stage,若吃掉事件 + * 会同时废掉远端操作和黑边唤出(R-7/4a 的判据会当场全红)。事件按 FrameLayout 规则 + * 下沉到 SurfaceView,行为与 4a 完全一致。 + */ +class CursorOverlayView(context: Context) : View(context) { + + /** 标记直径 22dp(Q2=A)。横屏 480dpi 下 ≈66px ≈ 桌面 33px 等比,够看清又不挡目标。 */ + private val markerRadiusPx = 11f * context.resources.displayMetrics.density + + /** + * 中心点兼作 proof 颜色特征码(青色非纯白):真机判据 V-D1 用 screencap 找这个 + * 颜色的像素簇并断言簇心 == 期望映射点。覆盖层绘制发生在解码**之后**的 UI 层, + * 不经过 H.264 有损链路,screencap 里颜色精确可匹配。圆环仍按 Q2 双描边。 + */ + private val dotColor = Color.rgb(0x00, 0xE5, 0xFF) + + private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = Color.WHITE + strokeWidth = 2f * context.resources.displayMetrics.density + } + private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = Color.BLACK + strokeWidth = 4f * context.resources.displayMetrics.density + } + private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = dotColor + } + + /** + * 当前帧头光标(桌面物理像素)。null = 无标记:还没收到带光标的帧、 + * 指针隐藏/离屏、或被控端后端未实现——三种情况都必须**什么都不画**。 + */ + @Volatile + private var cursor: Pair? = null + + /** 解码线程调用;null 也走这里(清除标记同样必须触发重绘,否则标记会留在原位)。 */ + fun updateCursor(newCursor: Pair?) { + cursor = newCursor + postInvalidate() + } + + /** 由 Activity 接线:几何锚点就是 SurfaceView(letterbox 后的实际画面矩形)。 */ + var anchor: RemoteSurfaceView? = null + + init { + isClickable = false + isFocusable = false + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val a = anchor ?: return + val c = cursor ?: return + val p = CursorMarker.mapToView( + desktopW = a.remoteWidth, + desktopH = a.remoteHeight, + cursor = c, + viewW = a.width, + viewH = a.height, + ) ?: return + // 视图内坐标 + 画面在 stage 里的原点偏移(黑边存在时不为 0) + val sx = a.x + p.x + val sy = a.y + p.y + // 双描边:先粗黑再细白——白环两侧各露 1dp 黑边,深浅背景都保底可见(Q2) + canvas.drawCircle(sx, sy, markerRadiusPx, outerRingPaint) + canvas.drawCircle(sx, sy, markerRadiusPx, ringPaint) + canvas.drawCircle(sx, sy, markerRadiusPx * 0.24f, dotPaint) + } +} 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 f8205f6..c357e03 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 @@ -37,6 +37,8 @@ import kotlin.concurrent.thread class MainActivity : Activity(), RemoteSurfaceView.Listener { private lateinit var surfaceView: RemoteSurfaceView + /** 4b:远端光标标记层(SurfaceView 的上层兄弟视图,常显无开关 Q6=A) */ + private lateinit var cursorOverlay: CursorOverlayView private lateinit var statusView: TextView private lateinit var connectPanel: LinearLayout private lateinit var remotePanel: LinearLayout @@ -345,6 +347,20 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { Gravity.CENTER, ), ) + // 4b:光标标记层叠在 SurfaceView 之上(后 addView = 更高层)。 + // 不能放进 SurfaceView 自己画:surface 合成在窗口之下,同视图无法自叠加。 + // MATCH_PARENT + 透明背景 + 不消费触摸(见 CursorOverlayView):事件仍按 + // FrameLayout 规则下沉到 SurfaceView,4a 的触摸与黑边唤出行为零变化。 + cursorOverlay = CursorOverlayView(this@MainActivity).apply { + anchor = surfaceView + } + addView( + cursorOverlay, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) } val keyboardButton = Button(this).apply { text = "键盘" @@ -860,6 +876,10 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { if (!stopDecoding) lostReason = "被控端不可达或会话已结束" break } + // 4b:每帧把帧头光标交给标记层(含 null——隐藏/离屏时清除标记)。 + // 放在解码入队之前:即使该帧解码排队失败(fed=false),画面内容仍是旧的, + // 但光标是帧头字段、与解码成败无关,标记跟帧走比跟画面解码走更正确。 + cursorOverlay.updateCursor(f.cursor) val fed = dec.queue(f) if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) { RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求 @@ -911,6 +931,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { // 断开后相对换算失去基准:不清零会让下一次会话在拿到新尺寸前按旧尺寸换算 surfaceView.remoteWidth = 0 surfaceView.remoteHeight = 0 + // 4b:标记必须随会话清除——旧会话的光标留在原地会冒充新会话的状态 + // (尤其「连 A 断开再连 B」,B 可能分辨率不同,旧坐标映射出的位置完全无意义) + cursorOverlay.updateCursor(null) lastFrames = 0 lastRtt = 0 lastFps = 0 @@ -937,6 +960,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener { remoteH = 0 surfaceView.remoteWidth = 0 surfaceView.remoteHeight = 0 + // 4b 同源检查:本函数与 cleanupSession 是两条并行收尾路径,标记清除必须同改 + cursorOverlay.updateCursor(null) lastFrames = 0 lastRtt = 0 lastFps = 0 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 af47943..3cc7b5c 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 @@ -16,8 +16,6 @@ class ConnectFailure(val kind: Int, val detail: String) : Exception(detail) */ object RustBridge { - private const val FRAME_HEADER_LEN = 25 - init { System.loadLibrary("androidffi") } @@ -31,7 +29,7 @@ object RustBridge { * 消息读走时槽位整体清空,颠倒顺序拿到 kind=0。 */ external fun nativeLastErrorKind(): Int - /** 返回 null = 会话结束;否则 [FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */ + /** 返回 null = 会话结束;否则 [FrameCodec.FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */ external fun nativeNextFrame(handle: Long): ByteArray? external fun nativeStats(handle: Long): Long external fun nativeDesktopSize(handle: Long): Long @@ -78,23 +76,21 @@ object RustBridge { val tsMs: Long, val width: Int, val height: Int, + /** + * v5:本帧发送时刻的远端光标(采集画面内物理像素)。 + * `null` = 隐藏/离屏/后端未实现(语义单一来源在被控端,见 common 协议注释)。 + * 用一个可空 Pair 而非两个可空 Int:忠实对应 `Option<(i16,i16)>`, + * 杜绝「x 有 y 无」这种协议里不存在的状态。 + */ + val cursor: Pair?, val payload: ByteArray, ) /** 取帧 + 拆头。null = 会话结束。 */ fun nextFrame(handle: Long): Frame? { val raw = nativeNextFrame(handle) ?: return null - if (raw.size < FRAME_HEADER_LEN) return null - return Frame( - keyframe = raw[0] != 0.toByte(), - seq = readLong(raw, 1), - tsMs = readLong(raw, 9), - width = readInt(raw, 17), - height = readInt(raw, 21), - payload = raw.copyOfRange(FRAME_HEADER_LEN, raw.size), - ) + return FrameCodec.parse(raw) } - fun rttMs(handle: Long): Long { val packed = nativeStats(handle) return if (packed < 0) 0 else (packed ushr 32) @@ -116,6 +112,41 @@ object RustBridge { val h = (packed and 0xFFFF_FFFF).toInt() return w to h } +} + +/** + * 帧头拆包(v5)。从 [RustBridge] 里独立出来的原因:**这个文件里的字节偏移必须能在 + * JVM 单测里被验证**,而 `RustBridge` object 带 `init { System.loadLibrary }`, + * 测试里一碰它就抛 `UnsatisfiedLinkError`(本机无 .so)。顶层 object 无 native、无 init, + * 构造嵌套类 [RustBridge.Frame] 也不触发外层 object 初始化——拆包逻辑因此可测。 + * + * 跨端契约:android-ffi/src/lib.rs 按 buf[0]/[1..9]/[9..17]/[17..21]/[21..25]/[25]/ + * [26..28]/[28..30] 写字节,这里按**同样的偏移**读。任一侧挪一位,两端都编译得过、 + * 真机也连得上——只是标记画错位置或永不显示(静默失败)。golden bytes 对拍锁死它, + * 见 RustBridgeTest。 + */ +internal object FrameCodec { + + // v5:[1 kf][8 seq][8 ts][4 w][4 h][1 has_cursor][2 x i16][2 y i16] = 30 + // (与 android-ffi/src/lib.rs 的 FRAME_HEADER_LEN 同步;新增字段一律追加尾部, + // 旧偏移 0/1/9/17/21 不移动——R-5 同源检查过:两处必须同改) + const val FRAME_HEADER_LEN = 30 + + /** 拆一帧。短于定长头 → null(截断/旧布局防御)。 */ + fun parse(raw: ByteArray): RustBridge.Frame? { + if (raw.size < FRAME_HEADER_LEN) return null + val hasCursor = raw[25] != 0.toByte() + return RustBridge.Frame( + keyframe = raw[0] != 0.toByte(), + seq = readLong(raw, 1), + tsMs = readLong(raw, 9), + width = readInt(raw, 17), + height = readInt(raw, 21), + // has=0 时坐标字节恒为 0 且无意义,绝不能读成 (0,0)——那会被画到左上角 + cursor = if (hasCursor) Pair(readI16(raw, 26), readI16(raw, 28)) else null, + payload = raw.copyOfRange(FRAME_HEADER_LEN, raw.size), + ) + } private fun readLong(b: ByteArray, off: Int): Long { var v = 0L @@ -129,4 +160,9 @@ object RustBridge { ((b[off + 2].toInt() and 0xFF) shl 16) or ((b[off + 3].toInt() and 0xFF) shl 24) } + + /** 按 little-endian **有符号** 16 位读(协议 cursor 是 i16;读成无符号会让负坐标穿透) */ + private fun readI16(b: ByteArray, off: Int): Int { + return ((b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)).toShort().toInt() + } }