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