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:
parent
f119300a57
commit
5047618c01
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user