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)。
真机手势验收待手机重新连接后补。
This commit is contained in:
郑博文 2026-10-30 09:51:38 +08:00
parent f119300a57
commit 5047618c01
5 changed files with 315 additions and 78 deletions

View File

@ -172,6 +172,22 @@ pub extern "system" fn Java_com_verify_remote_RustBridge_nativeStats(
(((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,

View File

@ -3,28 +3,26 @@ package com.verify.remote
import android.app.Activity
import android.os.Bundle
import android.util.Log
import android.view.MotionEvent
import android.view.KeyEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.widget.TextView
import kotlin.concurrent.thread
/**
* 阶段 1 验收 App:输入 IP/端口/验证码 → 连 agent.exe → 显示桌面。
*
* 刻意用最简结构(纯代码布局、无 AndroidX、无第三方库):
* 阶段 1 的目标是打通「QUIC 收帧 → MediaCodec 解码 → Surface 显示」,
* UI 框架与交互打磨属于阶段 2/3。
* 安卓主控端(阶段 2a):连接被控端 → MediaCodec 解码 → SurfaceView 显示 + 触摸操作。
*
* 线程模型(与 android-ffi 的锁纪律对应):
* - UI 线程:按钮连接/断开、触摸输入(nativeSend* 不持锁等待)
* - UI 线程:触摸/按键 → nativeSend*(入队不阻塞);连接/断开
* - 解码线程:nativeNextFrame 阻塞取帧 → H264Decoder.queue
* - 断开顺序:**先置 stopDecoding 并 join 解码线程,再 destroy**
* (Rust 侧文档明确:解码线程存活期间 destroy 是 UB)
* (Rust 侧明确:解码线程存活期间 destroy 是 UB)
*
* 布局:SurfaceView 按视频宽高比 letterbox 居中(RemoteSurfaceView),
* 触摸坐标只在画面区域内产生,与远端归一化坐标一一对应。
*/
class MainActivity : Activity() {
class MainActivity : Activity(), RemoteSurfaceView.Listener {
private lateinit var surfaceView: SurfaceView
private lateinit var surfaceView: RemoteSurfaceView
private lateinit var statusView: TextView
@Volatile
@ -34,95 +32,95 @@ class MainActivity : Activity() {
private var stopDecoding = false
private var decodeThread: Thread? = null
// 远端桌面尺寸(AuthOk 首帧头携带)
@Volatile
private var remoteW = 0
@Volatile
private var remoteH = 0
private var decoder: H264Decoder? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
surfaceView = SurfaceView(this).apply {
surfaceView = RemoteSurfaceView(this).apply {
listener = this@MainActivity
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 没了解码器输出会失效:直接断开,下次重连
// Surface 没了解码器输出会失效:断开,重进 App 重连
disconnect()
}
})
}
statusView = TextView(this).apply {
textSize = 13f
setPadding(24, 24, 24, 24)
textSize = 12f
setPadding(20, 12, 20, 12)
setBackgroundColor(0xCC000000.toInt())
setTextColor(0xFFFFFFFF.toInt())
}
val root = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
// 画面居中、黑边填充:LinearLayout 竖直 + gravity center
val stage = android.widget.FrameLayout(this).apply {
setBackgroundColor(0xFF000000.toInt())
addView(
surfaceView,
android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
)
)
addView(
statusView,
android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
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))
addView(statusView, android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT))
}
setContentView(root)
// 阶段 1 交互:启动即自动连接(参数走 intent extra,由 adb 传入),
// 双击画面断开。正式输入界面与触摸映射在阶段 2/3。
showHint()
val i = intent
val host = i?.getStringExtra("host")
if (host != null) {
connect(host, i.getIntExtra("port", 47800), i.getStringExtra("code") ?: "123456")
intent?.getStringExtra("host")?.let { host ->
connect(host, intent.getIntExtra("port", 47800), intent.getStringExtra("code") ?: "123456")
}
}
private fun showHint() {
statusView.text = buildString {
append(if (handle != 0L) "已连接 ${remoteW}x$remoteH\n" else "未连接\n")
append("双击画面=断开;连接参数走 intent:\n")
append("am start -n com.verify.remote/.MainActivity ")
append("--es host <PC_IP> --ei port 47800 --es code t")
statusView.text = if (handle != 0L) {
"已连接 ${remoteW}x$remoteH|单指=左键 双指轻点=右键 双指滑动=滚轮 返回键=断开"
} else {
"未连接|am start -n com.verify.remote/.MainActivity --es host <IP> --ei port 47800 --es code t"
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_UP -> {
val now = System.currentTimeMillis()
if (now - lastTap < 300) { // 双击:断开
disconnect()
return true
}
lastTap = now
}
MotionEvent.ACTION_MOVE -> {
// 阶段 1 输入:单指绝对坐标映射(远端桌面尺寸已知时)。
// 正式触摸交互(虚拟鼠标/手势)在阶段 2/3。
if (handle != 0L && remoteW > 0) {
val x = (event.x / surfaceView.width * 65535).toInt().coerceIn(0, 65535)
val y = (event.y / surfaceView.height * 65535).toInt().coerceIn(0, 65535)
RustBridge.nativeSendMouseMove(handle, x, y)
}
}
}
return super.onTouchEvent(event)
// ---- RemoteSurfaceView.Listener ----
override fun sendMouseMove(x: Int, y: Int) {
val h = handle
if (h != 0L) RustBridge.nativeSendMouseMove(h, x, y)
}
private var lastTap = 0L
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
@ -130,8 +128,17 @@ class MainActivity : Activity() {
thread(name = "connect") {
try {
val h = RustBridge.connect(host, port, code)
// 尺寸必须在解码前拿到:用它定 SurfaceView 宽高比。
// 若等首帧再改,视图会重新测量,把刚建好的解码器 surface 打掉。
val (w, ht) = RustBridge.desktopSize(h)
handle = h
runOnUiThread { showHint(); startDecodeLoop(h) }
remoteW = w
remoteH = ht
runOnUiThread {
if (w > 0 && ht > 0) surfaceView.videoAspectRatio = w.toFloat() / ht
showHint()
startDecodeLoop(h)
}
} catch (e: Exception) {
Log.e(TAG, "connect failed", e)
runOnUiThread { statusView.text = "连接失败: ${e.message}" }
@ -142,7 +149,6 @@ class MainActivity : Activity() {
private fun startDecodeLoop(h: Long) {
stopDecoding = false
decodeThread = thread(name = "decode") {
// Surface 可能尚未创建好,等它
val surface = surfaceView.holder.surface
var tries = 0
while (!surface.isValid && tries++ < 100) Thread.sleep(50)
@ -151,7 +157,6 @@ class MainActivity : Activity() {
return@thread
}
val dec = H264Decoder(surface)
decoder = dec
var frames = 0L
var lastKeyReq = 0L
while (!stopDecoding) {
@ -161,27 +166,21 @@ class MainActivity : Activity() {
Log.e(TAG, "nextFrame threw", e)
null
} ?: break
if (remoteW == 0) {
remoteW = f.width
remoteH = f.height
runOnUiThread { showHint() }
}
val fed = dec.queue(f)
if (!fed && f.keyframe.not() && System.currentTimeMillis() - lastKeyReq > 200) {
// 处于等 IDR 状态:限频请求关键帧
RustBridge.nativeRequestKeyframe(h)
if (!fed && !f.keyframe && System.currentTimeMillis() - lastKeyReq > 200) {
RustBridge.nativeRequestKeyframe(h) // 等 IDR 中:限频请求
lastKeyReq = System.currentTimeMillis()
}
frames++
if (frames % 120 == 0L) {
val rtt = RustBridge.rttMs(h)
runOnUiThread {
statusView.text = "已连接 ${remoteW}x$remoteH 帧=$frames RTT=${rtt}ms(双击断开)"
statusView.text =
"已连接 ${remoteW}x$remoteH 帧=$frames RTT=${rtt}ms|返回键断开"
}
}
}
dec.release()
decoder = null
Log.i(TAG, "decode loop ended, frames=$frames")
runOnUiThread {
statusView.text = "会话结束(帧 $frames)"
@ -194,9 +193,7 @@ class MainActivity : Activity() {
if (handle == 0L) return
stopDecoding = true
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,
// 6s 足够覆盖两者;超时仍 destroy 会 UB——阶段 2 在 Rust 侧加
// nativeShutdown(先关连接唤醒 nextFrame 再销毁)消除该竞态。
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,6s 足够覆盖。
decodeThread?.join(6000)
decodeThread = null
val h = handle

View File

@ -0,0 +1,200 @@
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
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(widthSpec, heightSpec)
val ar = videoAspectRatio
if (ar <= 0f) return
var w = measuredWidth
var h = measuredHeight
if (w <= 0 || h <= 0) return
if (w.toFloat() > h * ar) w = (h * ar).toInt() else h = (w / ar).toInt()
setMeasuredDimension(w, h)
}
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
override fun onTouchEvent(event: MotionEvent): Boolean {
val l = listener ?: return false
if (width == 0 || height == 0) return false
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
}
}

View File

@ -21,6 +21,7 @@ object RustBridge {
/** 返回 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)
@ -75,6 +76,18 @@ object RustBridge {
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))

View File

@ -153,6 +153,9 @@ pub struct Session {
/// 输入线程并发调 `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,
@ -210,6 +213,7 @@ impl Session {
)
.await
.context("send Auth")?;
let desktop: (u32, u32);
match read_control(&mut v_recv).await.context("read AuthOk")? {
ControlMsg::AuthOk {
width,
@ -221,6 +225,7 @@ impl Session {
"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}"),
@ -317,11 +322,17 @@ impl Session {
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> {