Author SHA1 Message Date
郑博文 5079d33b0a fix(agent): 显式 max_idle_timeout=10s——强杀主控端后 10 秒内可重连
问题(实测复现)
串行 accept 下,主控端被强杀(force-stop,非正常 close)时 QUIC 连接
不会立即消亡:agent 还在向死连接发帧,新连接只能排队,要等 quinn 默认
idle 超时(约 30s)才能建立。表现为「杀掉 App 重开,一直连不上」。

修法
TransportConfig 显式设 max_idle_timeout=10s。正常会话每 500ms 一个 Ping
永不触发该超时,只回收死连接。10s = 20 个 Ping 周期,抗抖动余量足够。

验证(vivo S6 实测)
- 会话1 auth ok -> force-stop App -> 立即重连
- 10.2s 后 peer connected + auth ok(修复前 ~30s)
2026-10-30 10:38:04 +08:00
郑博文 fe558a1c1e feat(android): 状态栏触摸诊断通道(vivo logcat 被限流的对策)
本机(vivo S6 + 办公 Windows)logcat 只能抓到 4 行,App 日志完全不可靠,
排查触摸/坐标问题时没有任何可用通道。

- RemoteSurfaceView 加 onDebug 回调:ACTION_DOWN/MOVE/UP 的视图坐标与
  发出的 norm 归一化坐标实时上报
- MainActivity 统一 refreshStatus():连接态 + 帧数/RTT + 最近触摸合并成
  一处刷新(原来 8 处各自 statusView.text 互相覆盖,且引用过已删的 showHint)
- 删掉临时步骤标记与 lastMeasureInfo(诊断价值已被 onDebug 覆盖)

实测:状态栏正确显示 UP view=(643,222) norm=(39017,23968) 1080x607,
据此确认了 letterbox 视图尺寸与归一化换算全对,把排查焦点正确引向
agent 注入侧(VIRTUALDESK,已另行修复)。
2026-10-30 10:35:21 +08:00
郑博文 6a6f989595 fix(agent): 绝对坐标注入去掉 VIRTUALDESK——注入空间与采集空间对齐
根因(实测确认)
capture 用 EnumOutputs(0) 只采**主屏**,AuthOk 报告的 width/height 也是主屏;
但 input 注入带 MOUSEEVENTF_VIRTUALDESK,归一化 0..=65535 被映射到**整个虚拟
桌面**。双显示器机器上虚拟桌面(主屏 2560x1440 + 副屏 1920x1080 在左)比主屏
宽得多,手机画面与指针位置系统性错位——点主屏任务栏,指针落到别处。
单显示器时两个空间重合,所以桌面 viewer 联调从未暴露。

修法
MOUSEEVENTF_ABSOLUTE 不带 VIRTUALDESK 时,0..=65535 恰好映射到主显示器,
与采集空间一致。协议注释「鼠标坐标为虚拟桌面归一化」的语义由「整个虚拟桌面」
修正为「被采集的输出」——桌面 viewer 是绝对定位透传,行为不变。

验证(vivo S6 -> 本机双屏,修复后)
- 手机拖微信标题栏:窗口跟随手指移动(drag-before/after 截图对比)
- App 状态栏 norm 坐标与视图坐标换算一致(view=(643,222) norm=(39017,23968))
2026-10-30 10:30:53 +08:00
郑博文 5047618c01 feat(android): 触摸映射 + letterbox 显示(阶段 2a 代码部分)
问题:阶段 1 的 SurfaceView 用 MATCH_PARENT,2560x1440 的视频被拉进
1080x2400 竖屏,画面变形。变形状态下手指位置与远端指针不再一一对应,
触摸映射无从谈起。所以两者必须一起做。

新增 RemoteSurfaceView
- onMeasure 按视频宽高比 letterbox:视图尺寸 = 画面尺寸,黑边不属于视图,
  触摸天然只在画面内产生事件,坐标归一化到 0..=65535 才准确
- 手势:单指轻点=左键、单指拖动=左键拖拽、双指轻点=右键、双指滑动=滚轮
- 刻意不做「手指即光标」的绝对跟随:手指会完全挡住目标,精细操作不可用
- 边界处理:进入拖拽后落下第二指,先抬起左键再切双指手势,
  否则远端会留下一个被按住未释放的按键

协议侧(不改线格式,只加只读查询)
- viewer-core::Session 记录 AuthOk 的桌面尺寸,新增 desktop_size()
- android-ffi 新增 nativeDesktopSize(handle) -> i64(高32=宽,低32=高)
- RustBridge.desktopSize() 拆包
- 尺寸必须在建解码器之前拿到:等首帧再改视图尺寸会触发重新测量,
  把刚创建的解码器 surface 打掉

MainActivity
- 接入 RemoteSurfaceView.Listener;连接成功后先取尺寸设宽高比,再起解码线程
- 返回键 = 断开(不退出 App,便于重连);去掉阶段 1 的双击断开
  (双击现在语义上是「两次左键点击」,会和远端交互冲突)

验证:cargo build --workspace 通过;cargo test 全绿(common 20 + viewer-core 3)。
真机手势验收待手机重新连接后补。
2026-10-30 09:51:38 +08:00
郑博文 f119300a57 feat(agent): 串行多会话——会话结束不再退出进程
问题(阶段 2 的硬阻塞)
accept() 只接一次,会话结束进程退出。阶段 1 一次验收看不出来,
但阶段 2 要反复测试输入,每测一次都要手动重启被控端。
(即公共阻碍清单里的 P5,此前判断为「只影响调试效率」,
 现在它阻塞迭代,提前做掉。)

改动
1. main:会话体抽成 serve_session(),外层 accept 循环。
   会话失败(含认证失败)只记日志不退出进程。
   明确不做并发多会话:DXGI Desktop Duplication 每个输出仅允许一个
   活动实例,第二个 DuplicateOutput 会让第一个收到 ACCESS_LOST。
2. 认证失败路径补 conn.closed() 等待(agent-core 已有同款修复,
   此处同源补齐 = 手册 §6.1 第 1 条):否则客户端只看到 connection
   lost 而不是 AuthFail。
3. capture 循环加 tx.receiver_count()==0 守卫。
   只靠「send 失败退出」不够——桌面完全静止时那个分支根本不发送,
   线程会永远持有 duplication,下一个会话的 DuplicateOutput 直接失败
   (表现为新会话黑屏)。agent-core 同源修复。
4. 会话收尾:finish 流 + drop 接收端 + 等采集线程退出(2s 超时)后才
   返回,调用方才 accept 下一个连接。

验证(连续两个会话,中间不重启 agent)
  会话1:收到帧 40/40,结论 ✅
  agent 存活:✅
  会话2:收到帧 40/40,结论 ✅
  日志:session closed -> waiting for next connection -> peer connected
        -> auth ok;stderr 有 [capture] no receiver, stop 证明线程真退出
2026-10-30 09:31:44 +08:00
郑博文 58bb66f4c2 feat(android): 阶段 1 安卓主控 App 工程(已真机验收出画面)
Kotlin 层三个文件(刻意零第三方依赖,纯 View 布局——阶段 1 只验证链路):
- RustBridge.kt:android-ffi 的 JNI 声明 + 帧头拆解(25 字节定长头 + Annex-B 载荷)
- H264Decoder.kt:MediaCodec 同步模式 + Surface 直出;内置两条实测教训——
  只用 getInputBuffer(i)(Exynos Quirk)、awaitingIdr 状态机(R-3 跳帧断参考链)
- MainActivity.kt:连接/解码循环/触摸绝对坐标映射;断开顺序遵守
  「先停解码线程 join 后 destroy」的 FFI 纪律

构建链(build-android.ps1,一条命令:cargo ndk -> .so -> gradle assembleDebug -> 安装启动):
- 路径自适应( 定位仓库根),local.properties 自动生成
- gradle.properties 固定 JDK 17,不动全局 JAVA_HOME

真机验收(vivo S6 / 192.168.1.163 -> PC / 192.168.1.180:47800):
- agent 日志:peer connected + auth ok, desktop 2560x1440
- 手机截图:Windows 桌面(WPS 文件列表)已渲染在 SurfaceView 上
  证据:evidence/screen-connected.png(本地,不入库)

已知环境限制(记录在个人层 baseline):本机 Windows 防火墙的 agent.exe
程序级放行规则对 UDP 入站不生效(临时关防火墙即通),QQPC 驱动级防护
静默拦 TCP 入站(管理员无法停止/杀死)。阶段 2 前需落定长期方案:
端口级防火墙规则或换开发机。
2026-10-29 19:59:43 +08:00
郑博文 64d497e577 test(viewer-core): headless_check 增加 --host,可指定被控端地址
默认仍 127.0.0.1。加 --host 后能用局域网 IP 跑同一验收,
便于排查「桌面通、手机不通」这类只影响入站路径的问题
(注:本机回环连接不经过防火墙,只有跨主机连接才验证防火墙路径)。
2026-10-29 19:18:11 +08:00
郑博文 44767ed370 feat(android-ffi): viewer-core 的 JNI 薄层,arm64 交叉编译通过(QUIC 栈上手机的第一道关)
方案变更说明(A2 落地修正):原裁决选 UniFFI,但实查 Maven Central 与
maven.mozilla.org 均无 uniffi Kotlin 运行时构件(404),自编译发布成本超过收益。
改为裸 JNI:阶段 0(真机)已验证该链路,接口刻意保持窄(连接/取帧/发输入/销毁),
将来若引入 UniFFI 只替换本层。

线程与锁纪律(决定接口形态):
- nativeNextFrame 等帧期间不持会话锁(clone Arc<Session> 后即释放),
  桌面静止 500ms 帧间隔不会卡住 UI 线程的输入调用
- destroy 前必须先停解码线程,Kotlin 侧顺序纪律保证(已登记风险清单)
- 句柄 = Box 裸指针(永不为 0),destroy 后置 0

帧返回约定:byte[] = [keyframe 标志 1][seq 8][ts 8][w 4][h 4][Annex-B payload],
定长 25 字节头,Kotlin 切片零 JNI 往返。

viewer-core 适配:Session.frames 改 Arc<tokio Mutex>,next_frame 取 &self,
使解码线程与输入线程可并发借用同一 Session。

验证:
- 桌面 cargo build -p android-ffi 零警告
- cargo ndk -t arm64-v8a --release:quinn/ring/rustls 交叉编译全部通过
  (消除阶段 1 最大未知:QUIC 栈能否编到 Android)
- libandroidffi.so 4.2MB,10 个 Java_com_verify_remote_RustBridge_* 符号导出正确
2026-10-29 18:27:34 +08:00
郑博文 34df7b070b feat(viewer-core): 新增安卓主控的连接/协议/收帧层(阶段 1 桌面验证通过)
定位(2026-10-29 方案裁决 A1-A4):
纯 Rust、平台无关,只做「怎么连 + 收发什么」——QUIC 会话、协议 v2 认证、
视频帧流、输入上行、Ping/Pong、KeyFrameReq。解码/渲染/触摸全在 Kotlin 侧,
本 crate 不碰像素、不依赖 openh264(dev-dependency 仅验证工具用)。

设计要点
- PeerTarget::Direct 是唯一的连接形态;不做中继、不定义 Relay 变体
  (《多平台架构设计》§6.1 裁决)。将来设备码+打洞只换 connect() 内部。
- 帧经容量 2 的 mpsc 交付,**不丢帧**:跳帧决策属于看得到解码器状态的消费方
  (Kotlin),Rust 层丢帧会重演 R-3 教训(跳帧断参考链)。
- insecure_skip_verify=false 时明确报错而非静默放行——证书钉扎登记在方案
  阶段 4,安全红线不允许默默降级。
- 双流结构与桌面 viewer 一致(流1 认证+视频,流2 输入+控制),入会即请求 IDR。

验证(headless,桌面→桌面 127.0.0.1)
- 单测 3 passed(帧契约、默认配置、strict-verify 显式报错)
- examples/headless_check:300 帧,seq 违例 0、keyframe 标志不符 0、
  解码成功 300/300、RTT 路径工作(RTT=1ms 出现)
- 全 workspace 回归:common 20 passed,构建全绿
2026-10-29 18:14:27 +08:00
22 changed files with 2109 additions and 26 deletions

No files matched your search

Generated
+155 -15
View File
@@ -31,6 +31,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "android-ffi"
version = "0.1.0"
dependencies = [
"anyhow",
"common",
"jni 0.21.1",
"tokio",
"viewer-core",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -76,7 +87,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"thiserror",
"thiserror 2.0.21",
]
[[package]]
@@ -121,6 +132,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -387,6 +404,22 @@ dependencies = [
"web-sys",
]
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@@ -396,10 +429,10 @@ dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
@@ -417,6 +450,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
@@ -764,7 +806,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
@@ -788,7 +830,7 @@ dependencies = [
"rustls-pki-types",
"rustls-platform-verifier",
"slab",
"thiserror",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
@@ -973,7 +1015,7 @@ checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
@@ -1231,13 +1273,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -1362,6 +1424,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "viewer-core"
version = "0.1.0"
dependencies = [
"anyhow",
"common",
"openh264",
"quinn",
"rustls",
"tokio",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -1678,13 +1752,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -1696,20 +1779,35 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
@@ -1721,18 +1819,36 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -1745,24 +1861,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
+3 -1
View File
@@ -2,7 +2,9 @@
resolver = "2"
# agent-core:平台无关会话层 + 软件编码视频管线(当前仅 linux-agent 使用)
# linux-agent:Linux 被控端(X11 + XTest)
members = ["common", "agent-core", "agent", "linux-agent", "viewer"]
# viewer-core:安卓主控的连接/协议/收帧层(桌面 headless 可验证;Android 经 JNI 复用)
# android-ffi:viewer-core 的 JNI 薄层(cdylib,交叉编译成 libandroidffi.so)
members = ["common", "agent-core", "agent", "linux-agent", "viewer", "viewer-core", "android-ffi"]
# 默认成员保持 Windows 三件套,`cargo build --release` 行为与改动前完全一致;
# Linux 被控端显式构建:`cargo build --release -p linux-agent`
default-members = ["common", "agent", "viewer"]
+7
View File
@@ -192,6 +192,13 @@ fn capture_session(
let mut seq: u64 = 0;
loop {
// 会话结束守卫:发送端已 drop 接收器时立即退出。
// 与 agent/src/capture.rs 同源——静止桌面分支不发送,只靠 send 失败退出
// 会让线程在画面不动时永不退出(X11 无 DXGI 式独占,危害较小但同样泄漏线程)。
if tx.receiver_count() == 0 {
eprintln!("[capture] no receiver, stop");
return LoopEnd::ChannelClosed;
}
let iter_start = Instant::now();
match cap.acquire(80) {
Ok(true) => match cap.copy_frame() {
+8
View File
@@ -387,6 +387,14 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
}
loop {
// 会话结束守卫:发送端已 drop 接收器时立即退出。
// 只靠 `tx.send` 失败退出是不够的——桌面完全静止时这里根本不发送,
// 线程会永远持有 duplication,导致下一个会话的 DuplicateOutput 失败
// (DXGI 每个输出仅允许一个活动实例)。
if tx.receiver_count() == 0 {
eprintln!("[capture] no receiver, stop");
return;
}
let iter_start = Instant::now();
match dup.acquire(80) {
Ok(true) => {
+8 -2
View File
@@ -76,9 +76,15 @@ pub fn virtual_desktop() -> (i32, i32, i32, i32) {
pub fn handle(ev: InputEvent) {
match ev {
InputEvent::MouseMove { x, y } => {
// 主控端已按虚拟桌面归一化,这里直接透传 0..65535
// 归一化坐标系 = **被采集的那个输出**(capture 用 EnumOutputs(0),
// 即主屏;AuthOk 的 width/height 也是它)。
// MOUSEEVENTF_ABSOLUTE 不带 VIRTUALDESK 时,0..=65535 恰好映射到
// 主显示器——与采集空间一致。
// 曾经带 VIRTUALDESK:多显示器机器上注入空间变成整个虚拟桌面
// (比主屏宽),手机画面与指针位置系统性错位(2026-10-30 实测)。
// 单显示器时两者重合,所以桌面端从未暴露此问题。
send_mouse(
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_VIRTUALDESK.0 | MOUSEEVENTF_MOVE.0,
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
x as i32,
y as i32,
0,
+53 -8
View File
@@ -1,6 +1,8 @@
//! 被控端 Agent:DXGI 桌面复制采集 → LZ4 压缩 → QUIC 下行视频;上行接收输入事件注入。
//! 被控端 Agent:DXGI 桌面复制采集 → H.264 编码 → QUIC 下行视频;上行接收输入事件注入。
//! P0 优化:视频帧经 watch 单槽通道(latest-wins,网络慢时只发最新帧,不积压);
//! 键鼠/控制走独立 QUIC 双向流,不与视频互相阻塞。
//! 会话模型:**串行多会话**——accept 循环一次只服务一个主控,会话结束后回到 accept
//! 等下一个连接(进程不退出)。改成并发多会话需要先解决 DXGI 单实例独占,暂不做。
mod capture;
mod input;
@@ -10,6 +12,7 @@ use common::{read_control, write_control, ControlMsg};
use rustls::pki_types::PrivatePkcs8KeyDer;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, watch};
@@ -50,6 +53,14 @@ async fn main() -> Result<()> {
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
transport.send_window(64 * 1024);
// 空闲超时:主控端被强杀(非正常 close)时收不到它的 Ping,连接会一直占住
// 串行 accept,下一个连接要等几十秒才能建立。10s = 20 个 Ping 周期,
// 正常会话(Ping 每 500ms)永不触发,只用来回收死连接。
transport.max_idle_timeout(Some(
Duration::from_secs(10)
.try_into()
.expect("10s fits in VarInt"),
));
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
server_config.transport_config(Arc::new(transport));
@@ -59,13 +70,31 @@ async fn main() -> Result<()> {
)?;
println!("[agent] listening 0.0.0.0:{port}, verify code = {code}");
let conn = endpoint
.accept()
.await
.ok_or_else(|| anyhow::anyhow!("endpoint closed"))?
.await?;
println!("[agent] peer connected: {}", conn.remote_address());
// 串行多会话:一次只服务一个主控,结束后回到 accept。
// 不做并发多会话——DXGI Desktop Duplication 每个输出仅允许一个活动实例,
// 第二个 DuplicateOutput 会让第一个收到 ACCESS_LOST。
// 会话失败(含认证失败)只记日志,不能让进程退出——否则每次连接都要重启被控端。
loop {
let Some(incoming) = endpoint.accept().await else {
println!("[agent] endpoint closed, exit");
return Ok(());
};
match incoming.await {
Ok(conn) => {
println!("[agent] peer connected: {}", conn.remote_address());
if let Err(e) = serve_session(conn, &code).await {
println!("[agent] session ended: {e:#}");
}
println!("[agent] waiting for next connection");
}
Err(e) => println!("[agent] handshake failed: {e}"),
}
}
}
/// 单次会话:认证 → 采集/发送/输入 → 会话结束。
/// 返回 Err 表示会话异常结束(含认证失败),调用方继续 accept。
async fn serve_session(conn: quinn::Connection, code: &str) -> Result<()> {
// 流 1:认证 + 视频下行
let (mut send, mut recv) = conn.accept_bi().await?;
@@ -93,6 +122,10 @@ async fn main() -> Result<()> {
if let Some(msg) = auth_err {
write_control(&mut send, &ControlMsg::AuthFail(msg.clone())).await?;
send.finish()?;
// 必须等对端读到 AuthFail 再关连接:直接返回会让进程带走 QUIC 缓冲区里
// 尚未发出的数据,客户端只会看到 "connection lost" 而不是 AuthFail。
// (agent-core 已有同款修复,此处同源补齐——手册 §6.1 第 1 条)
let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
anyhow::bail!(msg);
}
let (w, h) = capture::desktop_size()?;
@@ -117,7 +150,14 @@ async fn main() -> Result<()> {
// 拥塞反馈标志:发送侧 write 超过单帧预算(20ms) → 置位,采集线程跳帧降注入率
let congestion = Arc::new(AtomicBool::new(false));
let congestion_capture = congestion.clone();
std::thread::spawn(move || capture::run(tx, keyreq_capture, congestion_capture));
// 采集线程退出信号:会话结束必须等它真正退出才能 accept 下一个连接——
// DXGI Desktop Duplication 每个输出只允许一个活动实例,旧线程还持有 duplication
// 时新线程的 DuplicateOutput 会让旧实例收到 ACCESS_LOST(表现为下一个会话黑屏)。
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
capture::run(tx, keyreq_capture, congestion_capture);
let _ = done_tx.send(());
});
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
@@ -182,6 +222,11 @@ async fn main() -> Result<()> {
}
}
reader.abort();
// 关掉流并丢弃接收端,让采集线程下一次 send 失败而退出
let _ = send.finish();
drop(frames_rx);
// 等采集线程真正退出后再返回,调用方才会 accept 下一个连接(DXGI 独占,见上)
let _ = tokio::task::spawn_blocking(move || done_rx.recv_timeout(Duration::from_secs(2))).await;
println!("[agent] session closed");
Ok(())
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "android-ffi"
version = "0.1.0"
edition = "2021"
# 安卓主控的 JNI 薄层:把 viewer-core 的异步 Session 包成 Kotlin 可同步调用的句柄。
# 2026-10-29 裁决变更:原方案 A2 选 UniFFI,但其实训运行时不在 Maven Central /
# Mozilla maven(查证 404),需自编译发布,成本超过收益。改为裸 JNI——阶段 0
# (D:\working\android\phase0)已验证该链路真机可跑。接口刻意保持窄(连接/取帧/
# 发输入/销毁),将来若引入 UniFFI 也只是替换本层。
[lib]
name = "androidffi"
crate-type = ["cdylib"]
[dependencies]
viewer-core = { path = "../viewer-core" }
common = { path = "../common" }
anyhow.workspace = true
tokio.workspace = true
jni = "0.21"
+316
View File
@@ -0,0 +1,316 @@
//! android-ffi:viewer-core 的 JNI 薄层(阶段 1)。
//!
//! 线程模型(决定接口形态的原因):
//! - Kotlin 解码线程调用 `nativeNextFrame` 同步取帧(内部 block_on,一次一帧);
//! - Kotlin UI 线程调用 `nativeSend*` 发输入——tokio unbounded channel 入队不阻塞;
//! - 共用一个 current-thread runtime:`block_on(next_frame)` 期间 runtime 仍在驱动
//! QUIC 收发与 Ping;输入入队只是 channel send,不需要 runtime 参与。
//!
//! **锁纪律**:`nativeNextFrame` 在等帧期间**不得持有**会话锁——桌面静止时帧间隔
//! 可达 500ms,持锁会让 UI 线程的输入调用同步卡住。做法:短暂持锁克隆
//! `Arc<Session>` 与 `&'static Runtime` 引用后立即释放,再 block_on。
//! 代价:block_on 期间 destroy 会 use-after-free——阶段 1 由 Kotlin 侧
//! 「先停解码线程、后 destroy」的顺序纪律保证(登记在方案风险清单)。
//!
//! 句柄约定:`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)。
use common::InputEvent;
use jni::objects::{JClass, JString};
use jni::sys::{jbyteArray, jint, jlong, jstring};
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;
struct SessionBox {
session: Arc<Session>,
runtime: tokio::runtime::Runtime,
}
/// 连接被控端。成功返回句柄(>0),失败返回 0;错误消息经 `nativeLastError()` 取。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeConnect<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
host: JString<'local>,
port: jint,
code: JString<'local>,
) -> jlong {
let host_s = match env.get_string(&host) {
Ok(s) => s.to_string_lossy().into_owned(),
Err(_) => {
set_last_error("host 参数不是合法字符串");
return 0;
}
};
let code_s = match env.get_string(&code) {
Ok(s) => s.to_string_lossy().into_owned(),
Err(_) => {
set_last_error("code 参数不是合法字符串");
return 0;
}
};
let built = std::panic::catch_unwind(|| {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(r) => r,
Err(e) => return Err(format!("runtime init: {e}")),
};
let cfg = ConnectConfig::direct(host_s, port as u16, code_s);
match runtime.block_on(Session::connect(cfg)) {
Ok(session) => Ok(SessionBox {
session: Arc::new(session),
runtime,
}),
Err(e) => Err(format!("{e:#}")),
}
});
match built {
Ok(Ok(boxed)) => {
set_last_error("");
Box::into_raw(Box::new(Mutex::new(boxed))) as jlong
}
Ok(Err(msg)) => {
set_last_error(&msg);
0
}
Err(_) => {
set_last_error("panic in nativeConnect");
0
}
}
}
fn set_last_error(msg: &str) {
let mut slot = LAST_ERROR.lock().unwrap();
if msg.is_empty() {
*slot = None;
} else {
*slot = Some(msg.to_string());
}
}
static LAST_ERROR: Mutex<Option<String>> = Mutex::new(None);
/// 取最近一次 nativeConnect 的错误消息(无错误返回 null)。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeLastError<'local>(
env: JNIEnv<'local>,
_class: JClass<'local>,
) -> jstring {
let msg = LAST_ERROR.lock().unwrap().take();
match msg {
Some(m) => env
.new_string(m)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut()),
None => std::ptr::null_mut(),
}
}
/// 取下一帧(阻塞直到有帧或会话结束)。返回 null = 会话结束。
/// 仅允许解码线程调用;destroy 前必须先停止本线程(见文件头锁纪律)。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeNextFrame<'local>(
env: JNIEnv<'local>,
_class: JClass<'local>,
handle: jlong,
) -> jbyteArray {
// 短暂持锁:克隆 Arc 与拿 runtime 引用,随后立即释放——等帧不能挡输入。
let (session, runtime) = {
let Some(boxed) = handle_to_box(handle) else {
return std::ptr::null_mut();
};
let guard = lock_box(boxed);
(guard.session.clone(), &guard.runtime as *const tokio::runtime::Runtime)
};
// SAFETY: 见文件头「锁纪律」。Kotlin 保证解码线程存活期间不 destroy。
let rt = unsafe { &*runtime };
match rt.block_on(session.next_frame()) {
None => std::ptr::null_mut(),
Some(f) => {
let mut buf = vec![0u8; FRAME_HEADER_LEN + f.payload.len()];
buf[0] = f.keyframe as u8;
buf[1..9].copy_from_slice(&f.seq.to_le_bytes());
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());
buf[FRAME_HEADER_LEN..].copy_from_slice(&f.payload);
match env.byte_array_from_slice(&buf) {
Ok(a) => a.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
}
}
/// 会话统计打包:高 32 位 rtt_ms,低 32 位 frames_in 低 32 位。-1 = 句柄无效。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeStats(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jlong {
let Some(boxed) = handle_to_box(handle) else {
return -1;
};
let guard = lock_box(boxed);
let st = guard.session.stats();
// 打包进 i64:高 32 位 rtt_ms、低 32 位 frames_in,Kotlin 侧移位还原
(((st.rtt_ms & 0xFFFF_FFFF) << 32) | (st.frames_in & 0xFFFF_FFFF)) as jlong
}
/// 被控端桌面尺寸,打包成 i64:高 32 位宽、低 32 位高。0 = 句柄无效。
/// Kotlin 侧必须在建解码器之前调用,用返回值定 SurfaceView 宽高比。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeDesktopSize(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) -> jlong {
let Some(boxed) = handle_to_box(handle) else {
return 0;
};
let guard = lock_box(boxed);
let (w, h) = guard.session.desktop_size();
(((w as u64 & 0xFFFF_FFFF) << 32) | (h as u64 & 0xFFFF_FFFF)) as jlong
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseMove(
_env: JNIEnv,
_class: JClass,
handle: jlong,
x: jint,
y: jint,
) {
with_session(handle, |s| {
s.send_input(InputEvent::MouseMove {
x: x.clamp(0, 65535) as u16,
y: y.clamp(0, 65535) as u16,
})
});
}
/// button: 0=左 1=右 其他=中;down: 1=按下 0=抬起
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendMouseButton(
_env: JNIEnv,
_class: JClass,
handle: jlong,
button: jint,
down: jint,
) {
use common::MouseButton;
let b = match button {
0 => MouseButton::Left,
1 => MouseButton::Right,
_ => MouseButton::Middle,
};
with_session(handle, |s| {
s.send_input(if down != 0 {
InputEvent::MouseDown(b)
} else {
InputEvent::MouseUp(b)
})
});
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendWheel(
_env: JNIEnv,
_class: JClass,
handle: jlong,
dy: jint,
) {
with_session(handle, |s| s.send_input(InputEvent::Wheel { dy }));
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeSendKey(
_env: JNIEnv,
_class: JClass,
handle: jlong,
scancode: jint,
extended: jint,
down: jint,
) {
with_session(handle, |s| {
s.send_input(if down != 0 {
InputEvent::KeyDown {
scancode: scancode as u16,
extended: extended != 0,
}
} else {
InputEvent::KeyUp {
scancode: scancode as u16,
extended: extended != 0,
}
})
});
}
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeRequestKeyframe(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
with_session(handle, |s| s.request_keyframe());
}
/// 释放会话。Kotlin 侧调用后必须把句柄置 0,且先停解码线程。
#[no_mangle]
pub extern "system" fn Java_com_verify_remote_RustBridge_nativeDestroy(
_env: JNIEnv,
_class: JClass,
handle: jlong,
) {
if handle == 0 {
return;
}
let real = handle as *mut Mutex<SessionBox>;
unsafe {
let _ = Box::from_raw(real);
}
}
fn handle_to_box(handle: jlong) -> Option<&'static Mutex<SessionBox>> {
if handle == 0 {
return None;
}
let real = handle as *mut Mutex<SessionBox>;
// 悬垂指针无法在 Rust 侧检测:阶段 1 由 Kotlin「destroy 后置 0 + 先停线程」
// 的顺序纪律保证。违反即 UB,登记在方案风险清单(阶段 2 可加代际表消除)。
// NonNull::new 是安全函数;解引用才是 unsafe 点
std::ptr::NonNull::new(real).map(|p| unsafe { p.as_ref() })
}
/// 锁中毒容忍:SessionBox 内部无跨线程共享可变状态(Session 各方法内部自带
/// 并发原语),中毒后继续用是安全的。
fn lock_box(b: &Mutex<SessionBox>) -> std::sync::MutexGuard<'_, SessionBox> {
match b.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
}
}
fn with_session(handle: jlong, f: impl FnOnce(&Session)) {
let Some(boxed) = handle_to_box(handle) else {
return;
};
let guard = lock_box(boxed);
f(&guard.session);
}
+15
View File
@@ -0,0 +1,15 @@
# Gradle / Android 构建产物
.gradle/
build/
app/build/
local.properties
# Rust 交叉编译产物(由 build-android.ps1 生成到 app/libs)
app/libs/
# 验证截图
evidence/
# IDE
.idea/
*.iml
+56
View File
@@ -0,0 +1,56 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.verify.remote"
compileSdk = 34
defaultConfig {
applicationId = "com.verify.remote"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1-phase1"
// 阶段 1 只测真机 arm64;x86_64 留给模拟器回归
ndk {
abiFilters += listOf("arm64-v8a")
}
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
// .so 由外部脚本(build-android.ps1)预先拷到 app/libs/arm64-v8a/,
// 不让 Gradle 调 cargo——构建职责分离:Rust 侧失败与 Gradle 侧失败可立刻区分。
sourceSets {
getByName("main") {
jniLibs.srcDirs("libs")
}
}
packaging {
jniLibs {
// AGP 默认已按 useLegacyPackaging 处理;保持默认压缩+extractNativeLibs=false
// 由 AGP 保证 STORED+对齐,手动链的坑(lessons 记录)在 Gradle 下不存在
useLegacyPackaging = false
}
}
}
dependencies {
// 阶段 1 刻意零第三方 UI 依赖:纯 View 代码布局,减少变量。
// AndroidX 在阶段 2 做正式 UI 时再引入。
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 阶段 1:局域网连 agent.exe 需要网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Remote Viewer"
android:hasCode="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:keepScreenOn="true"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,159 @@
package com.verify.remote
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import android.view.Surface
import java.nio.ByteBuffer
/**
* MediaCodec H.264 解码器封装(阶段 1:同步模式 + Surface 直出)。
*
* 两条实测教训直接写进实现(来源见个人层 lessons / baseline):
* 1. Exynos 等设备带 requires-allocate-on-input/output-ports Quirk,
* 必须用 getInputBuffer(i)/getOutputBuffer(i),不用已废弃的数组版 API。
* 2. 参考帧链断裂后 P 帧必然解不出:解码线程维护 awaitingIdr 状态,
* 只喂 IDR 之后的帧;错误后 flush + requestKeyframe 并等 IDR(R-3)。
*
* MediaFormat 不设 csd:码流每个 IDR 自带 SPS/PPS(dump_stream 实测
* "SPS PPS IDR" 结构),解码器从码流内联参数集自举。
*/
class H264Decoder(private val surface: Surface) {
@Volatile
var stopped = false
private set
private var codec: MediaCodec? = null
private val bufferInfo = MediaCodec.BufferInfo()
/** 等待 IDR 标志:true 时 P 帧一律不喂解码器 */
private var awaitingIdr = true
private var configuredW = 0
private var configuredH = 0
/** 解码一帧。返回 false = 该帧被丢弃(等 IDR 中或缓冲满),不视为错误。 */
fun queue(frame: RustBridge.Frame): Boolean {
// 等待 IDR:参考链未建立/已断裂时,P 帧喂进去只会产生错误
if (awaitingIdr && !frame.keyframe) return false
if (frame.keyframe) awaitingIdr = false
val c = ensureCodec(frame) ?: return false
return try {
val idx = c.dequeueInputBuffer(INPUT_TIMEOUT_US)
if (idx < 0) return false // 输入满:阶段 1 直接丢,由消费方决定
val inBuf: ByteBuffer = c.getInputBuffer(idx) ?: return false
inBuf.clear()
inBuf.put(frame.payload)
c.queueInputBuffer(idx, 0, frame.payload.size, frame.tsMs * 1000, 0)
drainOnce(c)
true
} catch (e: MediaCodec.CodecException) {
Log.e(TAG, "codec error isRecoverable=${e.isRecoverable}", e)
invalidate()
false
} catch (e: IllegalStateException) {
Log.e(TAG, "codec illegal state", e)
invalidate()
false
}
}
/** 解码错误/跳帧后调用:flush 复位解码器(三级恢复第一级),
* 置等待标志;调用方负责 requestKeyframe。 */
fun invalidate() {
awaitingIdr = true
try {
codec?.flush()
} catch (e: Exception) {
// flush 失败说明解码器不可恢复:销毁,等下一个 IDR 重建
Log.w(TAG, "flush failed, releasing codec", e)
releaseCodec()
}
}
/** 确保解码器就绪。只在 keyframe 到来时才配置/重建(首配与重配同路径)。 */
private fun ensureCodec(frame: RustBridge.Frame): MediaCodec? {
val c = codec
if (c != null && configuredW == frame.width && configuredH == frame.height) {
return c
}
if (c != null) {
Log.i(TAG, "size changed ${configuredW}x$configuredH -> ${frame.width}x${frame.height}, reconfigure")
releaseCodec()
}
if (codec == null && frame.keyframe) {
val name = pickDecoderName()
val nc = if (name.isEmpty()) MediaCodec.createDecoderByType(MIME)
else MediaCodec.createByCodecName(name)
val fmt = MediaFormat.createVideoFormat(MIME, frame.width, frame.height)
nc.configure(fmt, surface, null, 0)
nc.start()
codec = nc
configuredW = frame.width
configuredH = frame.height
Log.i(TAG, "decoder configured ${frame.width}x${frame.height} name=${nc.name}")
}
return codec
}
/** 优先硬解;查不到就返回空串让系统自选(避免选中软件解码器)。 */
private fun pickDecoderName(): String {
return try {
android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
.codecInfos
.firstOrNull { info ->
!info.isEncoder &&
info.supportedTypes.any { t -> t.equals(MIME, true) } &&
!info.name.startsWith("OMX.google.") &&
!info.name.startsWith("c2.android.")
}?.name ?: ""
} catch (e: Exception) {
""
}
}
private fun drainOnce(c: MediaCodec) {
while (!stopped) {
val idx = c.dequeueOutputBuffer(bufferInfo, 0)
when {
idx == MediaCodec.INFO_TRY_AGAIN_LATER -> return
idx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED ->
Log.i(TAG, "output format: ${c.outputFormat}")
idx >= 0 -> {
// Surface 直出:渲染由解码器完成,不取图像数据
c.releaseOutputBuffer(idx, true)
}
else -> return
}
}
}
fun release() {
stopped = true
releaseCodec()
}
private fun releaseCodec() {
try {
codec?.stop()
} catch (e: Exception) {
Log.w(TAG, "stop ignored", e)
}
try {
codec?.release()
} catch (e: Exception) {
Log.w(TAG, "release ignored", e)
}
codec = null
configuredW = 0
configuredH = 0
}
companion object {
private const val TAG = "H264Decoder"
private const val MIME = "video/avc"
private const val INPUT_TIMEOUT_US = 4_000L
}
}
@@ -0,0 +1,236 @@
package com.verify.remote
import android.app.Activity
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.SurfaceHolder
import android.widget.TextView
import kotlin.concurrent.thread
/**
* 安卓主控端(阶段 2a):连接被控端 → MediaCodec 解码 → SurfaceView 显示 + 触摸操作。
*
* 线程模型(与 android-ffi 的锁纪律对应):
* - UI 线程:触摸/按键 → nativeSend*(入队不阻塞);连接/断开
* - 解码线程:nativeNextFrame 阻塞取帧 → H264Decoder.queue
* - 断开顺序:**先置 stopDecoding 并 join 解码线程,再 destroy**
* (Rust 侧明确:解码线程存活期间 destroy 是 UB)
*
* 布局:SurfaceView 按视频宽高比 letterbox 居中(RemoteSurfaceView),
* 触摸坐标只在画面区域内产生,与远端归一化坐标一一对应。
*/
class MainActivity : Activity(), RemoteSurfaceView.Listener {
private lateinit var surfaceView: RemoteSurfaceView
private lateinit var statusView: TextView
@Volatile
private var handle = 0L
@Volatile
private var stopDecoding = false
private var decodeThread: Thread? = null
@Volatile
private var remoteW = 0
@Volatile
private var remoteH = 0
/** 最近一次触摸事件(诊断用:logcat 被限流,状态栏是唯一可靠通道) */
@Volatile
private var lastTouchDbg = "-"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i(TAG, "onCreate start")
surfaceView = RemoteSurfaceView(this).apply {
listener = this@MainActivity
onDebug = { msg -> lastTouchDbg = msg; refreshStatus() }
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// Surface 没了解码器输出会失效:断开,重进 App 重连
disconnect()
}
})
}
statusView = TextView(this).apply {
textSize = 12f
setPadding(20, 12, 20, 12)
setBackgroundColor(0xCC000000.toInt())
setTextColor(0xFFFFFFFF.toInt())
}
// 画面居中、黑边填充。
// 用 FrameLayout + MATCH_PARENT(不是 WRAP_CONTENT):FrameLayout 会把
// 子视图的 WRAP_CONTENT 当 MATCH_PARENT 处理,onMeasure 里拿到的
// MeasureSpec 就成了 EXACTLY 全尺寸,letterbox 比例收缩被直接忽略
// (2026-10-30 实测:视图被撑成 1080x2001,触摸按 2001 归一化 → 坐标全错)。
// MATCH_PARENT + gravity=center 时父容器传 AT_MOST,onMeasure 的
// setMeasuredDimension 才真正决定尺寸,再被 gravity 居中摆放。
val stage = android.widget.FrameLayout(this).apply {
setBackgroundColor(0xFF000000.toInt())
addView(
surfaceView,
android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.view.Gravity.CENTER
)
)
}
val root = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
addView(stage, android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
// 固定高度而非 WRAP_CONTENT:与 weight=1 的兄弟共存时,
// WRAP_CONTENT 可能被压成 0(实测 bounds=[0,0][0,0],状态栏不可见)
addView(statusView, android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
(48 * resources.displayMetrics.density).toInt()))
}
setContentView(root)
refreshStatus()
intent?.getStringExtra("host")?.let { host ->
connect(host, intent.getIntExtra("port", 47800), intent.getStringExtra("code") ?: "123456")
}
}
/** 状态栏统一刷新:连接态 + 帧数/RTT + 最近触摸诊断(logcat 被限流,这是唯一通道) */
@Volatile
private var lastFrames = 0L
@Volatile
private var lastRtt = 0L
private fun refreshStatus() {
if (handle == 0L) {
statusView.text = "未连接|am start ... --es host <IP> --ei port 47800 --es code t"
return
}
statusView.text = buildString {
append("已连接 ${remoteW}x$remoteH 帧=$lastFrames RTT=${lastRtt}ms\n")
append("触摸: $lastTouchDbg")
}
}
// ---- RemoteSurfaceView.Listener ----
override fun sendMouseMove(x: Int, y: Int) {
val h = handle
if (h != 0L) RustBridge.nativeSendMouseMove(h, x, y)
}
override fun sendMouseButton(button: Int, down: Boolean) {
val h = handle
if (h != 0L) RustBridge.nativeSendMouseButton(h, button, if (down) 1 else 0)
}
override fun sendWheel(dy: Int) {
val h = handle
if (h != 0L) RustBridge.nativeSendWheel(h, dy)
}
/** 返回键 = 断开(不退出 App,便于重连) */
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK && handle != 0L) {
disconnect()
return true
}
return super.onKeyDown(keyCode, event)
}
// ---- 连接 / 解码 ----
private fun connect(host: String, port: Int, code: String) {
if (handle != 0L) return
statusView.text = "连接 $host:$port …"
thread(name = "connect") {
try {
val h = RustBridge.connect(host, port, code)
// 尺寸必须在解码前拿到:用它定 SurfaceView 宽高比。
// 若等首帧再改,视图会重新测量,把刚建好的解码器 surface 打掉。
val (w, ht) = RustBridge.desktopSize(h)
handle = h
remoteW = w
remoteH = ht
runOnUiThread {
if (w > 0 && ht > 0) {
surfaceView.videoAspectRatio = w.toFloat() / ht
}
refreshStatus()
startDecodeLoop(h)
}
} catch (e: Exception) {
Log.e(TAG, "connect failed", e)
runOnUiThread { statusView.text = "连接失败: ${e.message}" }
}
}
}
private fun startDecodeLoop(h: Long) {
stopDecoding = false
decodeThread = thread(name = "decode") {
val surface = surfaceView.holder.surface
var tries = 0
while (!surface.isValid && tries++ < 100) Thread.sleep(50)
if (!surface.isValid) {
Log.e(TAG, "surface never became valid")
return@thread
}
val dec = H264Decoder(surface)
var frames = 0L
var lastKeyReq = 0L
while (!stopDecoding) {
val f = try {
RustBridge.nextFrame(h)
} catch (e: Exception) {
Log.e(TAG, "nextFrame threw", e)
null
} ?: 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) {
lastFrames = frames
lastRtt = RustBridge.rttMs(h)
runOnUiThread { refreshStatus() }
}
}
dec.release()
Log.i(TAG, "decode loop ended, frames=$frames")
runOnUiThread { refreshStatus() }
}
}
private fun disconnect() {
if (handle == 0L) return
stopDecoding = true
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。
decodeThread?.join(6000)
decodeThread = null
val h = handle
handle = 0L
remoteW = 0
remoteH = 0
lastFrames = 0
lastRtt = 0
RustBridge.destroy(h)
refreshStatus()
}
override fun onDestroy() {
super.onDestroy()
disconnect()
}
companion object {
private const val TAG = "MainActivity"
}
}
@@ -0,0 +1,237 @@
package com.verify.remote
import android.content.Context
import android.view.MotionEvent
import android.view.SurfaceView
import kotlin.math.abs
import kotlin.math.hypot
/**
* 保持视频宽高比的 SurfaceView(letterbox),并把触摸手势映射为远端鼠标事件。
*
* 为什么视图尺寸必须等于画面尺寸:触摸坐标按视图宽高归一化到远端 0..=65535。
* 若视图被拉伸(阶段 1 用 MATCH_PARENT),画面与视图不再一一对应,手指位置与
* 远端指针位置就会错位;letterbox 后黑边不属于本视图,触摸天然只在画面内产生事件。
*
* 手势映射(桌面远控的通行方案,参考 Moonlight / Scrcpy 的交互形态,
* 实现为本项目独立编写):
* - 单指轻点 = 左键单击
* - 单指按下并移动 = 左键拖拽(窗口拖动、文字选中)
* - 双指轻点 = 右键
* - 双指上下滑动 = 滚轮
*
* 刻意不做「手指即光标」的绝对定位:手指会完全挡住目标,桌面精细操作不可用。
*/
class RemoteSurfaceView(context: Context) : SurfaceView(context) {
/** 由 Activity 实现,转发到 RustBridge */
interface Listener {
fun sendMouseMove(x: Int, y: Int)
fun sendMouseButton(button: Int, down: Boolean)
fun sendWheel(dy: Int)
}
var listener: Listener? = null
/** 视频宽高比(w/h)。>0 时视图按此比例测量。 */
var videoAspectRatio = 0f
set(value) {
if (value > 0f && field != value) {
field = value
requestLayout()
}
}
private enum class Mode { IDLE, TAP, DRAG, TWO_FINGER }
private var mode = Mode.IDLE
private var downX = 0f
private var downY = 0f
private var lastX = 0f
private var lastY = 0f
private var downTime = 0L
private var twoFingerStartY = 0f
private var twoFingerDownTime = 0L
private var twoFingerMoved = false
private var wheelAccum = 0f
/** 判定「拖拽 vs 点击」的位移阈值:12dp */
private val dragSlopPx = resources.displayMetrics.density * 12f
init {
isFocusable = true
}
/**
* letterbox 测量:在父容器给出的可用空间内按视频宽高比取最大内接矩形。
*
* 关键:父容器给 WRAP_CONTENT 时 MeasureSpec 是 AT_MOST,getSize() 是
* **上限**而非实际可用尺寸。据此算出的尺寸必须自己 setMeasuredDimension,
* 否则父容器会把视图撑满(2026-10-30 实测:视图变成 1080x2001 而非
* 1080x607,触摸按 2001 归一化 → 坐标全错,点中心落到画面外)。
*/
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
val ar = videoAspectRatio
if (ar <= 0f) {
super.onMeasure(widthSpec, heightSpec)
return
}
val availW = MeasureSpec.getSize(widthSpec)
val availH = MeasureSpec.getSize(heightSpec)
if (availW <= 0 || availH <= 0) {
super.onMeasure(widthSpec, heightSpec)
return
}
var w = availW
var h = (availW / ar).toInt()
if (h > availH) {
h = availH
w = (availH * ar).toInt()
}
setMeasuredDimension(w.coerceAtLeast(1), h.coerceAtLeast(1))
}
private fun normX(x: Float) = (x / width * 65535).toInt().coerceIn(0, 65535)
private fun normY(y: Float) = (y / height * 65535).toInt().coerceIn(0, 65535)
private fun moved(fromX: Float, fromY: Float, toX: Float, toY: Float) =
hypot((toX - fromX).toDouble(), (toY - fromY).toDouble()) > dragSlopPx
private fun midX(e: MotionEvent) =
if (e.pointerCount >= 2) (e.getX(0) + e.getX(1)) / 2f else e.x
private fun midY(e: MotionEvent) =
if (e.pointerCount >= 2) (e.getY(0) + e.getY(1)) / 2f else e.y
/** 诊断回调:把触摸情况显示到状态栏(本机 logcat 被系统限流,日志不可靠) */
var onDebug: ((String) -> Unit)? = null
override fun onTouchEvent(event: MotionEvent): Boolean {
val l = listener ?: run {
onDebug?.invoke("touch: listener=null")
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 -> {
downX = event.x; downY = event.y
lastX = event.x; lastY = event.y
downTime = System.currentTimeMillis()
mode = Mode.TAP
// 先把远端指针移到位,点击/拖拽都以此为基准
l.sendMouseMove(normX(event.x), normY(event.y))
}
MotionEvent.ACTION_POINTER_DOWN -> {
// 第二指落下 = 双指手势开始。若已进入拖拽(远端鼠标按着),
// 必须先抬起,否则远端会留下一个被按住未释放的左键。
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.TWO_FINGER
twoFingerMoved = false
wheelAccum = 0f
twoFingerStartY = midY(event)
lastX = midX(event); lastY = midY(event)
twoFingerDownTime = System.currentTimeMillis()
}
MotionEvent.ACTION_MOVE -> when (mode) {
Mode.TAP -> if (moved(downX, downY, event.x, event.y)) {
mode = Mode.DRAG
l.sendMouseButton(BTN_LEFT, true)
lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
}
Mode.DRAG -> {
lastX = event.x; lastY = event.y
l.sendMouseMove(normX(event.x), normY(event.y))
}
Mode.TWO_FINGER -> {
val cx = midX(event); val cy = midY(event)
if (abs(cy - twoFingerStartY) > dragSlopPx || moved(lastX, lastY, cx, cy)) {
twoFingerMoved = true
}
// 手指下移(cy 增大)= 内容向上翻 = Windows 滚轮向下 → dy 取负
wheelAccum += (cy - lastY)
val notches = (wheelAccum / WHEEL_PX_PER_NOTCH).toInt()
if (notches != 0) {
l.sendWheel(-notches * WHEEL_DELTA)
wheelAccum -= notches * WHEEL_PX_PER_NOTCH
}
lastX = cx; lastY = cy
}
Mode.IDLE -> {}
}
MotionEvent.ACTION_POINTER_UP -> {
// 双指之一抬起:先判定右键,再把剩余手指的移动视为已结束手势
if (mode == Mode.TWO_FINGER) {
maybeRightClick(l)
mode = Mode.IDLE
}
}
MotionEvent.ACTION_UP -> {
when (mode) {
Mode.TAP -> {
// 轻点 = 左键单击(位置已在 DOWN 时发送)
l.sendMouseButton(BTN_LEFT, true)
l.sendMouseButton(BTN_LEFT, false)
performClick()
}
Mode.DRAG -> l.sendMouseButton(BTN_LEFT, false)
Mode.TWO_FINGER -> maybeRightClick(l)
Mode.IDLE -> {}
}
mode = Mode.IDLE
}
MotionEvent.ACTION_CANCEL -> {
if (mode == Mode.DRAG) l.sendMouseButton(BTN_LEFT, false)
mode = Mode.IDLE
}
}
return true
}
/** 双指未移动且按住时间短 → 右键 */
private fun maybeRightClick(l: Listener) {
if (twoFingerMoved) return
if (System.currentTimeMillis() - twoFingerDownTime > TWO_FINGER_TAP_MAX_MS) return
l.sendMouseMove(normX(lastX), normY(lastY))
l.sendMouseButton(BTN_RIGHT, true)
l.sendMouseButton(BTN_RIGHT, false)
performClick()
}
@Suppress("ClickableViewAccessibility")
override fun performClick(): Boolean {
super.performClick()
return true
}
companion object {
const val BTN_LEFT = 0
const val BTN_RIGHT = 1
const val BTN_MIDDLE = 2
private const val WHEEL_DELTA = 120
private const val WHEEL_PX_PER_NOTCH = 60f
private const val TWO_FINGER_TAP_MAX_MS = 500L
}
}
@@ -0,0 +1,103 @@
package com.verify.remote
/**
* viewer-core(Rust)的 JNI 声明。符号约定:Java_com_verify_remote_RustBridge_*,
* 与 android-ffi/src/lib.rs 一一对应。
*
* 句柄纪律(Rust 侧文件头有完整说明):
* - nativeConnect 返回 >0 句柄;0 表示失败,错误消息取 [lastError]
* - destroy 后必须置 0;**destroy 前必须先停掉解码线程**(否则 use-after-free)
*/
object RustBridge {
private const val FRAME_HEADER_LEN = 25
init {
System.loadLibrary("androidffi")
}
private external fun nativeConnect(host: String, port: Int, code: String): Long
external fun nativeLastError(): String?
/** 返回 null = 会话结束;否则 [FRAME_HEADER_LEN] 字节头 + Annex-B 载荷 */
external fun nativeNextFrame(handle: Long): ByteArray?
external fun nativeStats(handle: Long): Long
external fun nativeDesktopSize(handle: Long): Long
external fun nativeSendMouseMove(handle: Long, x: Int, y: Int)
external fun nativeSendMouseButton(handle: Long, button: Int, down: Int)
external fun nativeSendWheel(handle: Long, dy: Int)
external fun nativeSendKey(handle: Long, scancode: Int, extended: Int, down: Int)
external fun nativeRequestKeyframe(handle: Long)
external fun nativeDestroy(handle: Long)
/** 连接。失败抛 IllegalStateException(带 Rust 侧错误消息)。 */
fun connect(host: String, port: Int, code: String): Long {
val h = nativeConnect(host, port, code)
if (h == 0L) {
throw IllegalStateException(nativeLastError() ?: "connect failed")
}
return h
}
fun destroy(handle: Long) {
if (handle != 0L) nativeDestroy(handle)
}
/** 解出的帧(头字段已拆,payload 可直接喂 MediaCodec) */
data class Frame(
val keyframe: Boolean,
val seq: Long,
val tsMs: Long,
val width: Int,
val height: 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),
)
}
fun rttMs(handle: Long): Long {
val packed = nativeStats(handle)
return if (packed < 0) 0 else (packed ushr 32)
}
fun framesIn(handle: Long): Long {
val packed = nativeStats(handle)
return if (packed < 0) 0 else packed and 0xFFFF_FFFF
}
/**
* 被控端桌面尺寸 (w, h)。连接成功后、建解码器之前调用。
* 返回 (0,0) 表示句柄无效。
*/
fun desktopSize(handle: Long): Pair<Int, Int> {
val packed = nativeDesktopSize(handle)
if (packed == 0L) return 0 to 0
val w = ((packed ushr 32) and 0xFFFF_FFFF).toInt()
val h = (packed and 0xFFFF_FFFF).toInt()
return w to h
}
private fun readLong(b: ByteArray, off: Int): Long {
var v = 0L
for (i in 0 until 8) v = v or ((b[off + i].toLong() and 0xFF) shl (8 * i))
return v
}
private fun readInt(b: ByteArray, off: Int): Int {
return (b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
}
}
+85
View File
@@ -0,0 +1,85 @@
# Phase 1 build chain: Rust .so -> jniLibs -> Gradle debug APK -> install -> launch
#
# Keep this file ASCII-only (PS 5.1 reads BOM-less .ps1 as GBK; see lessons).
#
# Usage (from apps/android-viewer/):
# powershell -ExecutionPolicy Bypass -File build-android.ps1 [-Host_ 192.168.1.5] [-Port 47800] [-Code t] [-SkipRust]
# (param is Host_ because $Host is a PowerShell automatic variable)
#
# Prereqs (Windows dev box):
# - Rust + Android targets + cargo-ndk + NDK (see personal docs/baseline.md toolchain table)
# - JDK 17 + Gradle 8.14 (paths in $vars below, adjust to your machine)
# - local.properties with sdk.dir (gitignored; template: sdk.dir=<android sdk path>)
param(
[string]$Host_ = "192.168.1.5",
[int]$Port = 47800,
[string]$Code = "t",
[switch]$SkipRust
)
$ErrorActionPreference = "Stop"
# repo root = two levels up from this script (apps/android-viewer -> repo)
$proj = $PSScriptRoot
$repo = (Resolve-Path "$proj\..\..").Path
$env_ = "D:\working\environment"
$gradle = "D:\working\android\gradle\gradle-8.14.3\bin\gradle.bat"
$adb = "D:\working\android\sdk\platform-tools\adb.exe"
$env:RUSTUP_HOME = "$env_\rust"
$env:CARGO_HOME = "$env_\cargo"
$env:PATH = "$env_\mingw64\bin;$env_\cargo\bin;$env:PATH"
$env:ANDROID_HOME = "D:\working\android\sdk"
$env:ANDROID_NDK_HOME = "D:\working\android\sdk\ndk\30.0.16248370"
$env:GRADLE_USER_HOME = "D:\working\android\gradle-home"
$env:JAVA_HOME = "D:\working\java\jdk17"
if (-not (Test-Path "$proj\local.properties")) {
Set-Content "$proj\local.properties" 'sdk.dir=D\:\\working\\android\\sdk' -Encoding ascii
Write-Host "generated local.properties (sdk.dir)"
}
if (-not $SkipRust) {
Write-Host "=== 1/4 cargo ndk: android-ffi (arm64 release) ===" -ForegroundColor Cyan
Push-Location $repo
# cargo writes progress to stderr; PS 5.1 + ErrorActionPreference=Stop turns
# every stderr line into a terminating error. Relax for native calls and
# judge by exit code (same for gradle below).
$ErrorActionPreference = "Continue"
cargo ndk -t arm64-v8a build -p android-ffi --release 2>&1 |
Select-String "error|Finished|Building"
if ($LASTEXITCODE -ne 0) { throw "cargo ndk build failed (exit $LASTEXITCODE)" }
$ErrorActionPreference = "Stop"
Pop-Location
}
Write-Host "=== 2/4 copy .so into app/libs ===" -ForegroundColor Cyan
$so = "$repo\target\aarch64-linux-android\release\libandroidffi.so"
if (-not (Test-Path $so)) { throw "missing $so (run without -SkipRust)" }
New-Item -ItemType Directory -Force -Path "$proj\app\libs\arm64-v8a" | Out-Null
Copy-Item $so "$proj\app\libs\arm64-v8a\libandroidffi.so" -Force
Write-Host (" libandroidffi.so: {0:N0} KB" -f ((Get-Item $so).Length/1KB))
Write-Host "=== 3/4 gradle assembleDebug ===" -ForegroundColor Cyan
Push-Location $proj
$ErrorActionPreference = "Continue"
& $gradle :app:assembleDebug --console=plain 2>&1 | Select-Object -Last 25
$code = $LASTEXITCODE
$ErrorActionPreference = "Stop"
Pop-Location
if ($code -ne 0) { throw "gradle build failed (exit $code)" }
$apk = "$proj\app\build\outputs\apk\debug\app-debug.apk"
if (-not (Test-Path $apk)) { throw "apk not found: $apk" }
Write-Host (" APK: {0:N0} KB" -f ((Get-Item $apk).Length/1KB))
Write-Host "=== 4/4 install + launch ===" -ForegroundColor Cyan
$ErrorActionPreference = "Continue"
& $adb install -r $apk
if ($LASTEXITCODE -ne 0) { throw "install failed (phone must allow USB install)" }
& $adb logcat -c | Out-Null
& $adb shell am start -n com.verify.remote/.MainActivity `
--es host $Host_ --ei port $Port --es code $Code | Out-Null
$ErrorActionPreference = "Stop"
Write-Host "launched. watch: adb logcat -s MainActivity:H264Decoder RustBridge:* viewer-core:* AndroidRuntime:E"
Write-Host "after 10s capture screen: adb exec-out screencap -p > screen.png"
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.9.2" apply false
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
}
+4
View File
@@ -0,0 +1,4 @@
# 固定 JDK 17(不动全局 JAVA_HOME,遵守 2026-10-29 环境约束)
org.gradle.java.home=D\:\\working\\java\\jdk17
android.useAndroidX=false
org.gradle.jvmargs=-Xmx2g
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "remote-viewer"
include(":app")
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "viewer-core"
version = "0.1.0"
edition = "2021"
# 阶段 1 先做纯 Rust 库并在桌面 headless 验证;
# UniFFI / cdylib 在下一步(Android 接入)再加,隔离未知项。
[lib]
name = "viewer_core"
crate-type = ["lib"]
[dependencies]
common = { path = "../common" }
anyhow.workspace = true
tokio.workspace = true
quinn.workspace = true
rustls.workspace = true
[dev-dependencies]
# headless_check 用软解交叉验证收到的码流合法(库本身刻意不依赖 openh264,
# 解码在 Android 侧由 MediaCodec 承担)
openh264 = { version = "0.9.9", features = ["source"] }
+149
View File
@@ -0,0 +1,149 @@
//! viewer-core 的桌面 headless 验证工具(阶段 1 验收用,不依赖 Android)。
//!
//! 证明三件事,逐条打印:
//! 1. viewer-core 能完成 QUIC 连接 + 协议 v2 认证握手;
//! 2. 帧流按契约交付(seq 单调递增、keyframe 标志与码流实际 NAL 一致);
//! 3. 收到的码流能被 openh264 解出画面(即:Android 侧 MediaCodec 将拿到同样
//! 合法的 Annex-B——解码器不同,但码流合法性在这里就证明了)。
//!
//! 用法:
//! 终端1: target\release\agent.exe --port 47900 --code t
//! 终端2: cargo run -p viewer-core --bin headless_check -- --port 47900 --code t --frames 60
use anyhow::Result;
use std::time::Duration;
use viewer_core::{ConnectConfig, Session};
fn arg(name: &str) -> Option<String> {
let a: Vec<String> = std::env::args().collect();
a.iter()
.position(|x| x == name)
.and_then(|i| a.get(i + 1))
.cloned()
}
/// 与 dump_stream 同款:扫 Annex-B 起始码拿 NAL 类型,交叉校验 keyframe 标志
fn has_idr(payload: &[u8]) -> bool {
let mut i = 0usize;
while i + 3 < payload.len() {
let sc = if payload[i..].starts_with(&[0, 0, 0, 1]) {
4
} else if payload[i..].starts_with(&[0, 0, 1]) {
3
} else {
i += 1;
continue;
};
match payload.get(i + sc) {
Some(&b) if b & 0x1F == 5 => return true,
Some(_) => i += sc + 1,
None => break,
}
}
false
}
#[tokio::main]
async fn main() -> Result<()> {
let port: u16 = arg("--port")
.map(|s| s.parse())
.transpose()?
.unwrap_or(47800);
let code = arg("--code").unwrap_or_else(|| "123456".into());
// --host:默认 127.0.0.1;填局域网 IP 可顺带验证防火墙入站路径
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
let frames_wanted: u64 = arg("--frames")
.map(|s| s.parse())
.transpose()?
.unwrap_or(60);
let mut session = Session::connect(ConnectConfig::direct(&host, port, code)).await?;
println!("[check] 连接成功 host={host} peer={}", session.peer_addr());
let mut decoder = openh264::decoder::Decoder::new()?;
let mut last_seq: Option<u64> = None;
let mut got = 0u64;
let mut seq_violations = 0u64;
let mut kf_mismatches = 0u64;
let mut decoded = 0u64;
let mut decode_failed_before_idr = 0u64;
let mut awaiting_idr = true; // 与 Android 侧相同的状态机
let mut rtt_shown = false;
let deadline = std::time::Instant::now() + Duration::from_secs(30);
while got < frames_wanted && std::time::Instant::now() < deadline {
let Some(f) = tokio::time::timeout(Duration::from_secs(10), session.next_frame())
.await
.ok()
.flatten()
else {
break;
};
got += 1;
// 契约 2a:seq 单调递增
if let Some(prev) = last_seq {
if f.seq <= prev {
seq_violations += 1;
println!("[check] ⚠️ seq 回退: {prev} -> {}", f.seq);
}
}
last_seq = Some(f.seq);
// 契约 2b:keyframe 标志与码流一致
let real_kf = has_idr(&f.payload);
if f.keyframe != real_kf {
kf_mismatches += 1;
println!(
"[check] ⚠️ 帧头 keyframe={} 但码流实际 IDR={}(seq={})",
f.keyframe, real_kf, f.seq
);
}
// 契约 3:与 Android 相同的状态机下解码必须成功
if awaiting_idr && !f.keyframe {
continue; // 等 IDR,P 帧不喂(这是 R-3 教训的实现)
}
if f.keyframe {
awaiting_idr = false;
}
match decoder.decode(&f.payload) {
Ok(_) => decoded += 1,
Err(e) => {
decode_failed_before_idr += 1;
println!("[check] ❌ seq={} 解码失败: {e}", f.seq);
awaiting_idr = true;
session.request_keyframe();
}
}
// RTT 出现即记录一次
if !rtt_shown {
let s = session.stats();
if s.rtt_ms > 0 {
println!("[check] RTT={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
rtt_shown = true;
}
}
}
let s = session.stats();
println!("\n========== 阶段 1 headless 验收 ==========");
println!("收到帧: {got}(目标 {frames_wanted})");
println!("seq 违例: {seq_violations}");
println!("keyframe 标志不符: {kf_mismatches}");
println!("解码成功: {decoded} / 解码失败: {decode_failed_before_idr}");
println!("统计: rtt={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
let ok = got >= frames_wanted.min(30)
&& seq_violations == 0
&& kf_mismatches == 0
&& decoded > 0
&& decode_failed_before_idr == 0;
println!(
"结论: {}",
if ok { "✅ viewer-core 收帧链路通过" } else { "❌ 存在未满足项" }
);
session.close().await;
Ok(())
}
+431
View File
@@ -0,0 +1,431 @@
//! viewer-core:安卓主控的连接 / 协议 / 收帧层(纯 Rust,平台无关)。
//!
//! 设计边界(对齐《多平台架构设计》与 2026-10-29 方案裁决):
//! - **只做「怎么连 + 收发什么」**:QUIC 会话、认证握手、视频帧流、输入上行、
//! Ping/Pong、KeyFrameReq。解码(MediaCodec)、渲染(SurfaceView)、触摸映射
//! 全在 Kotlin 侧,本 crate 不碰像素,也不引入 openh264。
//! - **连接层是唯一的可替换点**:`PeerTarget::Direct` 现在只支持 `IP:端口` 局域网
//! 直连;将来设备码 + 信令 + 打洞只换 `connect()` 内部。**不定义 Relay 变体**
//! (中继已被团队裁决排除,见《多平台架构设计》§6.1)。
//! - 帧经 mpsc 通道交付,**不丢帧**:消费方(Kotlin)按 `keyframe` 标志自行决定
//! 跳帧策略,且跳帧后必须 `request_keyframe()` 并等 IDR(协议 v2 提供该字段)。
//! 丢帧决策放在这里会让 Rust 层承担它看不到的解码器状态。
//!
//! 安全现状:`insecure_skip_verify` 默认 true(与桌面 viewer 的 SkipVerify 一致,
//! 仅限局域网测试)。产品化前必须换成信令下发的证书指纹钉扎(`signaling.rs`
//! 已有 `CertFingerprint`),该项登记在方案阶段 4。
use anyhow::{bail, Context, Result};
use common::{
read_control, read_video_frame, write_control, ControlMsg, InputEvent, VideoFrameHeader,
PROTO_VERSION,
};
use rustls::client::danger::{
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::net::ToSocketAddrs;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
/// 连接目标。当前只有局域网直连;设备码 + 打洞在后续阶段替换 `connect()` 内部实现。
#[derive(Debug, Clone)]
pub enum PeerTarget {
/// 局域网 / 可直连地址
Direct { host: String, port: u16 },
}
/// 一次会话的参数
#[derive(Debug, Clone)]
pub struct ConnectConfig {
pub target: PeerTarget,
/// 被控端验证码
pub code: String,
/// 仅测试用:跳过服务端证书校验(与桌面 viewer 行为一致)。
/// 公网/产品形态必须为 false 并启用证书钉扎。
pub insecure_skip_verify: bool,
}
impl ConnectConfig {
/// 局域网测试的便捷构造
pub fn direct(host: impl Into<String>, port: u16, code: impl Into<String>) -> Self {
Self {
target: PeerTarget::Direct {
host: host.into(),
port,
},
code: code.into(),
insecure_skip_verify: true,
}
}
}
/// 交付给消费方的一帧视频。字段语义与 `common::VideoFrameHeader` 一致,
/// payload 为标准 Annex-B H.264(可直接喂 MediaCodec)。
#[derive(Debug, Clone)]
pub struct Frame {
pub width: u32,
pub height: u32,
pub ts_ms: u64,
pub seq: u64,
pub keyframe: bool,
pub payload: Vec<u8>,
}
impl From<(VideoFrameHeader, Vec<u8>)> for Frame {
fn from((h, payload): (VideoFrameHeader, Vec<u8>)) -> Self {
Self {
width: h.width,
height: h.height,
ts_ms: h.ts_ms,
seq: h.seq,
keyframe: h.keyframe,
payload,
}
}
}
/// 会话状态快照(供 UI 显示,不做 EMA——那属于 Kotlin 的 HUD 层)
#[derive(Debug, Clone, Copy, Default)]
pub struct SessionStats {
/// 最近一次 Ping 往返(ms),0 = 还没测出
pub rtt_ms: u64,
/// 收到的帧数
pub frames_in: u64,
/// 收到的字节数(视频载荷累计)
pub bytes_in: u64,
}
/// MVP:跳过服务端证书校验(自签证书,仅局域网)。
#[derive(Debug)]
struct SkipVerify;
impl ServerCertVerifier for SkipVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
/// 输入/控制命令(写入流 2)
enum Command {
Input(InputEvent),
KeyFrameReq,
Ping(u64),
}
/// 已建立的会话。Drop 时后台任务随连接关闭自然退出;显式 `close()` 更干净。
pub struct Session {
tx: mpsc::UnboundedSender<Command>,
/// 帧接收端。通道关闭 = 会话结束(被控端断开或流错误)。
/// Mutex 包一层是为了让 `next_frame` 取 `&self`:JNI 层里解码线程独占 recv,
/// 输入线程并发调 `send_input`(走无锁的 unbounded tx),两者互不阻塞。
frames: Arc<tokio::sync::Mutex<mpsc::Receiver<Frame>>>,
stats: Arc<std::sync::Mutex<SessionStats>>,
/// 被控端桌面尺寸(AuthOk 携带)。主控端据此在解码前定好视图宽高比——
/// 若等首帧再改尺寸会触发 SurfaceView 重新测量,把刚创建的解码器 surface 打掉。
desktop: (u32, u32),
/// 保持 Endpoint 存活(drop 会销毁连接)
_endpoint: quinn::Endpoint,
conn: quinn::Connection,
}
impl Session {
/// 建立连接并完成认证握手 + 首帧关键帧请求。
pub async fn connect(cfg: ConnectConfig) -> Result<Self> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
let tls = if cfg.insecure_skip_verify {
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipVerify))
.with_no_client_auth()
} else {
// 证书钉扎尚未接入(方案阶段 4)。这里明确报错而不是静默放行。
bail!("insecure_skip_verify=false 需要证书钉扎,尚未实现(见方案阶段 4)");
};
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls)
.try_into()
.context("build QUIC client config")?;
// 接收窗口放大:视频下行大流量,quinn 默认 512KB 流窗口会卡吞吐
// (与桌面 viewer / agent 的实测调优一致)
let mut transport = quinn::TransportConfig::default();
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
client_config.transport_config(Arc::new(transport));
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
endpoint.set_default_client_config(client_config);
let PeerTarget::Direct { host, port } = &cfg.target;
let addr = (host.as_str(), *port)
.to_socket_addrs()?
.next()
.with_context(|| format!("resolve {host}:{port}"))?;
let conn = endpoint
.connect(addr, "localhost")?
.await
.with_context(|| format!("QUIC connect {addr}"))?;
// ---- 流 1:认证 + 视频下行 ----
let (mut v_send, mut v_recv) = conn.open_bi().await?;
write_control(
&mut v_send,
&ControlMsg::Auth {
code: cfg.code.clone(),
proto_version: PROTO_VERSION,
},
)
.await
.context("send Auth")?;
let desktop: (u32, u32);
match read_control(&mut v_recv).await.context("read AuthOk")? {
ControlMsg::AuthOk {
width,
height,
proto_version,
} => {
if proto_version != PROTO_VERSION {
bail!(
"protocol mismatch: agent={proto_version}, viewer-core={PROTO_VERSION}"
);
}
desktop = (width, height);
log(&format!("auth ok, remote desktop {width}x{height}"));
}
ControlMsg::AuthFail(e) => bail!("auth failed: {e}"),
m => bail!("unexpected first message: {m:?}"),
}
// ---- 流 2:输入 + 控制 + Ping/Pong ----
let (mut ctl_send, mut ctl_recv) = conn.open_bi().await?;
// 入会即请求 IDR:从 GOP 中间接入时裸 P 帧解不出来(桌面端同款教训)
write_control(&mut ctl_send, &ControlMsg::KeyFrameReq).await?;
let stats = Arc::new(std::sync::Mutex::new(SessionStats::default()));
// 帧通道:容量 2 足够——消费方(Kotlin)每帧都要喂解码器,
// 积压超过 2 帧说明该跳帧的是消费方的决策,不是这里的。
let (frame_tx, frames) = mpsc::channel::<Frame>(2);
{
let stats = stats.clone();
tokio::spawn(async move {
loop {
match read_video_frame(&mut v_recv).await {
Ok((h, payload)) => {
{
let mut s = stats.lock().unwrap();
s.frames_in += 1;
s.bytes_in += payload.len() as u64;
}
let frame: Frame = (h, payload).into();
if frame_tx.send(frame).await.is_err() {
break; // 消费方已退出
}
}
Err(e) => {
log(&format!("video stream ended: {e}"));
break;
}
}
}
});
}
// 命令通道 → 流 2 写任务
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
tokio::spawn(async move {
loop {
let Some(cmd) = cmd_rx.recv().await else {
break;
};
let msg = match cmd {
Command::Input(ev) => ControlMsg::Input(ev),
Command::KeyFrameReq => ControlMsg::KeyFrameReq,
Command::Ping(t) => ControlMsg::Ping(t),
};
if write_control(&mut ctl_send, &msg).await.is_err() {
break;
}
}
});
// 流 2 读任务:Pong → RTT
{
let stats = stats.clone();
tokio::spawn(async move {
loop {
match read_control(&mut ctl_recv).await {
Ok(ControlMsg::Pong { t, .. }) => {
let rtt = monotonic_ms().saturating_sub(t);
stats.lock().unwrap().rtt_ms = rtt;
}
Ok(_) => {}
Err(e) => {
log(&format!("control stream ended: {e}"));
break;
}
}
}
});
}
// Ping 任务:500ms 一次(与桌面 viewer 一致)
{
let cmd_tx_ping = cmd_tx.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
if cmd_tx_ping.send(Command::Ping(monotonic_ms())).is_err() {
break;
}
}
});
}
Ok(Self {
tx: cmd_tx,
frames: Arc::new(tokio::sync::Mutex::new(frames)),
stats,
desktop,
_endpoint: endpoint,
conn,
})
}
/// 被控端桌面尺寸 `(width, height)`,来自 AuthOk,解码前即可用。
pub fn desktop_size(&self) -> (u32, u32) {
self.desktop
}
/// 取下一帧。`None` = 会话结束。
/// `&self`:JNI 层里解码线程独占 recv,输入线程可并发 `send_input`。
pub async fn next_frame(&self) -> Option<Frame> {
self.frames.lock().await.recv().await
}
/// 发送一个输入事件(非阻塞入队,由写任务串行发出)。
pub fn send_input(&self, ev: InputEvent) {
let _ = self.tx.send(Command::Input(ev));
}
/// 请求被控端强制关键帧(解码器错误/跳帧后的恢复路径)。
pub fn request_keyframe(&self) {
let _ = self.tx.send(Command::KeyFrameReq);
}
/// 当前会话统计快照。
pub fn stats(&self) -> SessionStats {
*self.stats.lock().unwrap()
}
/// 被控端地址(日志/UI 用)
pub fn peer_addr(&self) -> String {
self.conn.remote_address().to_string()
}
/// 主动结束会话。
pub async fn close(self) {
let _ = self.conn.close(0u32.into(), b"viewer closing");
}
}
/// 单调毫秒时钟(进程启动起算)。Ping/Pong 两端共用同一口径即可测 RTT。
fn monotonic_ms() -> u64 {
use std::sync::OnceLock;
static T0: OnceLock<Instant> = OnceLock::new();
T0.get_or_init(Instant::now).elapsed().as_millis() as u64
}
/// 轻量日志出口。阶段 2 接 UniFFI 时替换为 Kotlin 侧回调。
fn log(msg: &str) {
eprintln!("[viewer-core] {msg}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_from_header_preserves_contract_fields() {
let h = VideoFrameHeader {
width: 1920,
height: 1080,
ts_ms: 42,
seq: 7,
keyframe: true,
};
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);
assert_eq!(f.payload, payload);
}
#[test]
fn direct_config_defaults_to_insecure_lan_mode() {
let c = ConnectConfig::direct("192.168.1.5", 47800, "abc");
assert!(c.insecure_skip_verify);
match c.target {
PeerTarget::Direct { host, port } => {
assert_eq!(host, "192.168.1.5");
assert_eq!(port, 47800);
}
}
}
#[tokio::test]
async fn strict_verify_mode_is_explicit_unimplemented_error() {
// 安全红线不能静默:显式要求校验时必须是明确错误,而不是悄悄放行。
// 不用 unwrap_err():它要求 Ok 类型(Session)实现 Debug,而连接对象
// 的 Debug 输出没有验证价值。
let cfg = ConnectConfig {
target: PeerTarget::Direct {
host: "127.0.0.1".into(),
port: 1,
},
code: "x".into(),
insecure_skip_verify: false,
};
match Session::connect(cfg).await {
Err(e) => assert!(e.to_string().contains("证书钉扎"), "实际错误: {e}"),
Ok(_) => panic!("insecure_skip_verify=false 必须明确报错,不得静默放行"),
}
}
}