feat(android): v5 光标标记层(拆包迁移 FrameCodec + CursorMarker + Overlay + 接线)

4b 手机端「看得见远端鼠标」的实现(协议 v5 的消费端):

- RustBridge:Frame 增 cursor: Pair<Int,Int>?(可空 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。
This commit is contained in:
郑博文 2026-11-02 11:05:09 +08:00
parent 4379b01d41
commit 9a6f6962d2
4 changed files with 219 additions and 13 deletions

View File

@ -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<Int, Int>?,
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(),
)
}
}

View File

@ -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<Int, Int>? = null
/** 解码线程调用;null 也走这里(清除标记同样必须触发重绘,否则标记会留在原位)。 */
fun updateCursor(newCursor: Pair<Int, Int>?) {
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)
}
}

View File

@ -37,6 +37,8 @@ import kotlin.concurrent.thread
class MainActivity : Activity(), RemoteSurfaceView.Listener { class MainActivity : Activity(), RemoteSurfaceView.Listener {
private lateinit var surfaceView: RemoteSurfaceView private lateinit var surfaceView: RemoteSurfaceView
/** 4b:远端光标标记层(SurfaceView 的上层兄弟视图,常显无开关 Q6=A) */
private lateinit var cursorOverlay: CursorOverlayView
private lateinit var statusView: TextView private lateinit var statusView: TextView
private lateinit var connectPanel: LinearLayout private lateinit var connectPanel: LinearLayout
private lateinit var remotePanel: LinearLayout private lateinit var remotePanel: LinearLayout
@ -345,6 +347,20 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
Gravity.CENTER, 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 { val keyboardButton = Button(this).apply {
text = "键盘" text = "键盘"
@ -860,6 +876,10 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
if (!stopDecoding) lostReason = "被控端不可达或会话已结束" if (!stopDecoding) lostReason = "被控端不可达或会话已结束"
break break
} }
// 4b:每帧把帧头光标交给标记层(含 null——隐藏/离屏时清除标记)。
// 放在解码入队之前:即使该帧解码排队失败(fed=false),画面内容仍是旧的,
// 但光标是帧头字段、与解码成败无关,标记跟帧走比跟画面解码走更正确。
cursorOverlay.updateCursor(f.cursor)
val fed = dec.queue(f) val fed = dec.queue(f)
if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) { if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) {
RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求 RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求
@ -911,6 +931,9 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
// 断开后相对换算失去基准:不清零会让下一次会话在拿到新尺寸前按旧尺寸换算 // 断开后相对换算失去基准:不清零会让下一次会话在拿到新尺寸前按旧尺寸换算
surfaceView.remoteWidth = 0 surfaceView.remoteWidth = 0
surfaceView.remoteHeight = 0 surfaceView.remoteHeight = 0
// 4b:标记必须随会话清除——旧会话的光标留在原地会冒充新会话的状态
// (尤其「连 A 断开再连 B」,B 可能分辨率不同,旧坐标映射出的位置完全无意义)
cursorOverlay.updateCursor(null)
lastFrames = 0 lastFrames = 0
lastRtt = 0 lastRtt = 0
lastFps = 0 lastFps = 0
@ -937,6 +960,8 @@ class MainActivity : Activity(), RemoteSurfaceView.Listener {
remoteH = 0 remoteH = 0
surfaceView.remoteWidth = 0 surfaceView.remoteWidth = 0
surfaceView.remoteHeight = 0 surfaceView.remoteHeight = 0
// 4b 同源检查:本函数与 cleanupSession 是两条并行收尾路径,标记清除必须同改
cursorOverlay.updateCursor(null)
lastFrames = 0 lastFrames = 0
lastRtt = 0 lastRtt = 0
lastFps = 0 lastFps = 0

View File

@ -16,8 +16,6 @@ class ConnectFailure(val kind: Int, val detail: String) : Exception(detail)
*/ */
object RustBridge { object RustBridge {
private const val FRAME_HEADER_LEN = 25
init { init {
System.loadLibrary("androidffi") System.loadLibrary("androidffi")
} }
@ -31,7 +29,7 @@ object RustBridge {
* 消息读走时槽位整体清空,颠倒顺序拿到 kind=0。 * 消息读走时槽位整体清空,颠倒顺序拿到 kind=0。
*/ */
external fun nativeLastErrorKind(): Int 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 nativeNextFrame(handle: Long): ByteArray?
external fun nativeStats(handle: Long): Long external fun nativeStats(handle: Long): Long
external fun nativeDesktopSize(handle: Long): Long external fun nativeDesktopSize(handle: Long): Long
@ -78,23 +76,21 @@ object RustBridge {
val tsMs: Long, val tsMs: Long,
val width: Int, val width: Int,
val height: Int, val height: Int,
/**
* v5:本帧发送时刻的远端光标(采集画面内物理像素)。
* `null` = 隐藏/离屏/后端未实现(语义单一来源在被控端,见 common 协议注释)。
* 用一个可空 Pair 而非两个可空 Int:忠实对应 `Option<(i16,i16)>`,
* 杜绝「x 有 y 无」这种协议里不存在的状态。
*/
val cursor: Pair<Int, Int>?,
val payload: ByteArray, val payload: ByteArray,
) )
/** 取帧 + 拆头。null = 会话结束。 */ /** 取帧 + 拆头。null = 会话结束。 */
fun nextFrame(handle: Long): Frame? { fun nextFrame(handle: Long): Frame? {
val raw = nativeNextFrame(handle) ?: return null val raw = nativeNextFrame(handle) ?: return null
if (raw.size < FRAME_HEADER_LEN) return null return FrameCodec.parse(raw)
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),
)
} }
fun rttMs(handle: Long): Long { fun rttMs(handle: Long): Long {
val packed = nativeStats(handle) val packed = nativeStats(handle)
return if (packed < 0) 0 else (packed ushr 32) return if (packed < 0) 0 else (packed ushr 32)
@ -116,6 +112,41 @@ object RustBridge {
val h = (packed and 0xFFFF_FFFF).toInt() val h = (packed and 0xFFFF_FFFF).toInt()
return w to h 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 { private fun readLong(b: ByteArray, off: Int): Long {
var v = 0L var v = 0L
@ -129,4 +160,9 @@ object RustBridge {
((b[off + 2].toInt() and 0xFF) shl 16) or ((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24) ((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()
}
} }