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:
parent
4379b01d41
commit
9a6f6962d2
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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<Int, Int>?,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user