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_* 符号导出正确
This commit is contained in:
郑博文 committed 2026-10-29 18:27:34 +08:00
1 parent 34df7b070b
commit 44767ed370
5 files changed
+473 -21

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
//! 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
}
#[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);
}