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 前需落定长期方案: 端口级防火墙规则或换开发机。
This commit is contained in:
parent
64d497e577
commit
58bb66f4c2
15
apps/android-viewer/.gitignore
vendored
Normal file
15
apps/android-viewer/.gitignore
vendored
Normal 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
apps/android-viewer/app/build.gradle.kts
Normal file
56
apps/android-viewer/app/build.gradle.kts
Normal 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 时再引入。
|
||||||
|
}
|
||||||
21
apps/android-viewer/app/src/main/AndroidManifest.xml
Normal file
21
apps/android-viewer/app/src/main/AndroidManifest.xml
Normal file
@ -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,218 @@
|
|||||||
|
package com.verify.remote
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.MotionEvent
|
||||||
|
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。
|
||||||
|
*
|
||||||
|
* 线程模型(与 android-ffi 的锁纪律对应):
|
||||||
|
* - UI 线程:按钮连接/断开、触摸输入(nativeSend* 不持锁等待)
|
||||||
|
* - 解码线程:nativeNextFrame 阻塞取帧 → H264Decoder.queue
|
||||||
|
* - 断开顺序:**先置 stopDecoding 并 join 解码线程,再 destroy**
|
||||||
|
* (Rust 侧文档明确:解码线程存活期间 destroy 是 UB)
|
||||||
|
*/
|
||||||
|
class MainActivity : Activity() {
|
||||||
|
|
||||||
|
private lateinit var surfaceView: SurfaceView
|
||||||
|
private lateinit var statusView: TextView
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var handle = 0L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
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 {
|
||||||
|
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 没了解码器输出会失效:直接断开,下次重连
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
statusView = TextView(this).apply {
|
||||||
|
textSize = 13f
|
||||||
|
setPadding(24, 24, 24, 24)
|
||||||
|
setBackgroundColor(0xCC000000.toInt())
|
||||||
|
setTextColor(0xFFFFFFFF.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = android.widget.LinearLayout(this).apply {
|
||||||
|
orientation = android.widget.LinearLayout.VERTICAL
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastTap = 0L
|
||||||
|
|
||||||
|
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)
|
||||||
|
handle = h
|
||||||
|
runOnUiThread { showHint(); 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") {
|
||||||
|
// Surface 可能尚未创建好,等它
|
||||||
|
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)
|
||||||
|
decoder = dec
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
lastKeyReq = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
frames++
|
||||||
|
if (frames % 120 == 0L) {
|
||||||
|
val rtt = RustBridge.rttMs(h)
|
||||||
|
runOnUiThread {
|
||||||
|
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)"
|
||||||
|
showHint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnect() {
|
||||||
|
if (handle == 0L) return
|
||||||
|
stopDecoding = true
|
||||||
|
// 顺序纪律:等解码线程退出后才能 destroy(见 android-ffi 文件头)。
|
||||||
|
// 卡帧风险:agent 静止时 500ms 一帧、QUIC idle 超时后流必关,
|
||||||
|
// 6s 足够覆盖两者;超时仍 destroy 会 UB——阶段 2 在 Rust 侧加
|
||||||
|
// nativeShutdown(先关连接唤醒 nextFrame 再销毁)消除该竞态。
|
||||||
|
decodeThread?.join(6000)
|
||||||
|
decodeThread = null
|
||||||
|
val h = handle
|
||||||
|
handle = 0L
|
||||||
|
remoteW = 0
|
||||||
|
remoteH = 0
|
||||||
|
RustBridge.destroy(h)
|
||||||
|
showHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MainActivity"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
apps/android-viewer/build-android.ps1
Normal file
85
apps/android-viewer/build-android.ps1
Normal 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
apps/android-viewer/build.gradle.kts
Normal file
4
apps/android-viewer/build.gradle.kts
Normal 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
apps/android-viewer/gradle.properties
Normal file
4
apps/android-viewer/gradle.properties
Normal 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
apps/android-viewer/settings.gradle.kts
Normal file
17
apps/android-viewer/settings.gradle.kts
Normal 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")
|
||||||
Loading…
Reference in New Issue
Block a user