feat(viewer-core,android-ffi): v5 光标字段透传到 FFI(帧头 25→30 字节)

主控链路把协议帧头的光标坐标交给 Kotlin:
- viewer-core::Frame 增 cursor: Option<(i16,i16)>,From 实现透传;
  契约测试改为 Some((640,480)) 并断言透传——丢它=标记永不显示且丢法是静默的
- android-ffi 定长头 25→30:尾部追加 [has_cursor 1][x i16 LE 2][y i16 LE 2],
  放末尾保证 Kotlin 既有偏移量(0/1/9/17/21)全部不移动
- None → has=0 且坐标字节恒 0(无意义);Some 分支显式写两坐标,
  杜绝「Some 但忘写坐标」的编辑事故(文件头注释同步 30 字节布局)
- R-5 同源检查:RustBridge.kt 的 FRAME_HEADER_LEN=25 与拆头偏移是本次协议变更的
  同步改动点,Kotlin 侧在下一提交完成(同一循环、相邻提交)
This commit is contained in:
郑博文 2026-11-02 10:39:19 +08:00
parent ad46fc805b
commit 8eed1e20e1
2 changed files with 29 additions and 6 deletions

View File

@ -15,9 +15,13 @@
//! 句柄约定:`nativeConnect` 返回 Box 裸指针(永不为 0)作为 jlong;
//! `nativeDestroy` 释放。句柄释放后不得再使用,Kotlin 侧用置 0 保护。
//!
//! 帧返回约定:byte[],布局 = [1 标志][8 seq LE][8 ts LE][4 w LE][4 h LE][payload]。
//! 定长头 25 字节;Kotlin 读 buf[0] 判断 keyframe(标志位复用为:0=P 帧, 1=IDR 帧,
//! 会话结束则整个返回 null)。
//! 帧返回约定:byte[],布局 = [1 标志][8 seq LE][8 ts LE][4 w LE][4 h LE]
//! [1 has_cursor][2 cursorX i16 LE][2 cursorY i16 LE][payload]。
//! 定长头 30 字节(v5 起;此前 25);Kotlin 读 buf[0] 判断 keyframe(标志位复用为:
//! 0=P 帧, 1=IDR 帧,会话结束则整个返回 null)。
//! has_cursor=0 时后 4 字节恒为 0 且**无意义**(指针隐藏/离屏/后端未实现,
//! 语义单一来源在被控端——见 common::VideoFrameHeader::cursor);=1 时坐标为
//! 采集画面内物理像素(0 ≤ x < w、0 ≤ y < h,被控端已折叠越界值)。
use common::InputEvent;
use jni::objects::{JCharArray, JClass, JObject, JString};
@ -26,8 +30,9 @@ use jni::JNIEnv;
use std::sync::{Arc, Mutex};
use viewer_core::{ConnectConfig, Session};
/// 帧头长度:keyframe 标志(1) + seq(8) + ts(8) + w(4) + h(4)
const FRAME_HEADER_LEN: usize = 25;
/// 帧头长度:keyframe 标志(1) + seq(8) + ts(8) + w(4) + h(4) + has_cursor(1) + x(2) + y(2)
/// (v5:25→30,尾部三字段是光标;放末尾保证 Kotlin 旧偏移量全部不移动)
const FRAME_HEADER_LEN: usize = 30;
struct SessionBox {
session: Arc<Session>,
@ -168,6 +173,17 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeNextFrame<'local>
buf[9..17].copy_from_slice(&f.ts_ms.to_le_bytes());
buf[17..21].copy_from_slice(&f.width.to_le_bytes());
buf[21..25].copy_from_slice(&f.height.to_le_bytes());
// v5 光标三字段(偏移 25/26/28):None → has=0 且坐标字节保持 0(无意义)。
// Option 展开成显式分支而不是 f.cursor.is_some() as u8——两处读同一个
// Option,任何「Some 但忘写坐标」的编辑错误都会在这里编译期暴露。
match f.cursor {
Some((x, y)) => {
buf[25] = 1;
buf[26..28].copy_from_slice(&x.to_le_bytes());
buf[28..30].copy_from_slice(&y.to_le_bytes());
}
None => buf[25] = 0,
}
buf[FRAME_HEADER_LEN..].copy_from_slice(&f.payload);
match env.byte_array_from_slice(&buf) {
Ok(a) => a.into_raw(),

View File

@ -198,6 +198,10 @@ pub struct Frame {
pub ts_ms: u64,
pub seq: u64,
pub keyframe: bool,
/// v5:帧发送时刻的远端光标位置(采集画面内物理像素)。
/// `None` = 隐藏/离屏/后端未实现(语义单一来源在被控端,见 `VideoFrameHeader::cursor`)。
/// 桌面 viewer 不消费该字段(看着自己的鼠标),安卓据它画标记。
pub cursor: Option<(i16, i16)>,
pub payload: Vec<u8>,
}
@ -209,6 +213,7 @@ impl From<(VideoFrameHeader, Vec<u8>)> for Frame {
ts_ms: h.ts_ms,
seq: h.seq,
keyframe: h.keyframe,
cursor: h.cursor,
payload,
}
}
@ -678,13 +683,15 @@ mod tests {
ts_ms: 42,
seq: 7,
keyframe: true,
cursor: None,
cursor: Some((640, 480)),
};
let payload = vec![0u8; 16];
let f: Frame = (h.clone(), payload.clone()).into();
assert_eq!((f.width, f.height), (1920, 1080));
assert_eq!(f.seq, 7);
assert!(f.keyframe);
// v5:光标字段必须透传,丢它=标记永远不显示,且丢法是静默的
assert_eq!(f.cursor, Some((640, 480)));
assert_eq!(f.payload, payload);
}